package web import ( "encoding/json" "errors" "log/slog" "net/http" "time" "pete/internal/games/blackjack" "pete/internal/games/uno" "pete/internal/storage" ) // UNO, played for chips at a shared table. // // Like hold'em, this is a session: you sit down with a stack, ante into a pot each // hand, and leave with what is in front of you. Chips cross the border twice — // sit-down and get-up — and every ante and pot in between moves inside the state // blob. Solo play is just a table nobody else has joined. // // The seam is the same as hold'em: one request plays a human's move plus every // bot turn it hands off to, returned as a script the felt animates. What a viewer // is allowed to see is their own hand, the card and colour in play, the pot, and // how many cards each other seat holds — never the deck, another seat's hand, or // the face of a card a bot drew. The engine emits every seat's hand (a shared // stream cannot know who is watching); the redaction that keeps a hand private is // here, and a missed case fans it to every subscriber. // unoCardView is one card, ready to draw: colour and face as words, not the // engine's integers. type unoCardView struct { Color string `json:"color"` Value string `json:"value"` Wild bool `json:"wild"` } func viewUnoCard(c uno.Card) unoCardView { return unoCardView{Color: c.Color.String(), Value: c.Value.String(), Wild: c.IsWild()} } // unoSeatView is one seat at the table: a name, a card count, and a stack. A // seat's cards are a *count* — there is no field here for what they are. type unoSeatView struct { Name string `json:"name"` Bot bool `json:"bot"` You bool `json:"you"` Cards int `json:"cards"` Stack int64 `json:"stack"` Ante int64 `json:"ante,omitempty"` Uno bool `json:"uno"` // down to one card Called bool `json:"called"` // …and said so. Uno true and this false is a seat you can catch Out bool `json:"out"` // not in this hand — mercy-killed, or sitting one out } // unoView is a table as one seat may see it. type unoView struct { Tier uno.Tier `json:"tier"` YourSeat int `json:"your_seat"` Seats []unoSeatView `json:"seats"` Hand []unoCardView `json:"hand"` // yours, and only yours Playable []int `json:"playable"` // which of them can legally go down Top unoCardView `json:"top"` // the card in play Color string `json:"color"` // the colour in play, which a wild renames Deck int `json:"deck"` // cards left to draw UnoAt []int `json:"uno_at"` // your cards that would leave you on one Catchable []int `json:"catchable"` // seats sitting on one card they never called Turn int `json:"turn"` Dir int `json:"dir"` Dealer int `json:"dealer"` HandNo int `json:"hand_no"` Pending int `json:"pending"` // No Mercy: the bill a stack has run up Pot int64 `json:"pot"` // the antes riding on this hand Ante int64 `json:"ante"` // what each seat puts in Stack int64 `json:"stack"` // what's in front of you BoughtIn int64 `json:"bought_in"` // your own buy-in, from the border ledger Phase string `json:"phase"` // The last hand's verdict, so the felt can land it between hands. Winner int `json:"winner"` LastPot int64 `json:"last_pot,omitempty"` Rake int64 `json:"rake,omitempty"` Outcome string `json:"outcome,omitempty"` } // viewUno renders the table as one seat may see it. viewer is which seat is // looking — their hand is the only one it will ever put in the payload. // // This is the security boundary. The same view fans to every subscriber's stream, // so a seat that renders anyone else's cards fans them to the whole table. // TestUnoViewNeverLeaksAnotherSeatsCards renders every seat's view and greps for // cards that are not theirs. func viewUno(g uno.State, viewer int) unoView { v := unoView{ Tier: g.Tier, YourSeat: viewer, Top: viewUnoCard(g.Top()), Color: g.Color.String(), Deck: g.Left(), Turn: g.Turn, Dir: g.Dir, Dealer: g.Dealer, HandNo: g.HandNo, Pending: g.Pending, Pot: g.Pot, Ante: g.Tier.Ante, BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake Phase: string(g.Phase), Winner: g.Winner, LastPot: g.LastPot, Rake: g.Rake, Outcome: string(g.Outcome), } if viewer >= 0 && viewer < len(g.Seats) { v.Stack = g.Seats[viewer].Stack } counts := g.Counts() for i := range g.Seats { p := g.Seats[i] live := g.Live(i) seat := unoSeatView{ Name: p.Name, Bot: p.Bot, You: i == viewer, Cards: counts[i], Stack: p.Stack, Ante: p.Ante, Uno: live && counts[i] == 1, Called: i < len(g.Called) && g.Called[i], Out: !live, } v.Seats = append(v.Seats, seat) } // The wall. Only the viewer's own hand crosses the wire. if viewer >= 0 && viewer < len(g.Hands) { for _, c := range g.Hands[viewer] { v.Hand = append(v.Hand, viewUnoCard(c)) } } if v.Hand == nil { v.Hand = []unoCardView{} } // Empty arrays, never null: the felt indexes into these. v.Playable = g.Playable(viewer) if v.Playable == nil { v.Playable = []int{} } v.UnoAt = g.UnoAt(viewer) if v.UnoAt == nil { v.UnoAt = []int{} } v.Catchable = g.Catchable(viewer) if v.Catchable == nil { v.Catchable = []int{} } return v } // unoEventView is one beat of the script the felt plays back. The engine attaches // every seat's hand and drawn cards; this is the wall where the ones the viewer // isn't entitled to are stripped. type unoEventView struct { Kind string `json:"kind"` Seat int `json:"seat"` Card *unoCardView `json:"card,omitempty"` Color string `json:"color,omitempty"` N int `json:"n,omitempty"` Left int `json:"left"` By int `json:"by"` Text string `json:"text,omitempty"` Hand []unoCardView `json:"hand,omitempty"` } func viewUnoEvents(evs []uno.Event, viewer int) []unoEventView { out := make([]unoEventView, 0, len(evs)) for _, e := range evs { v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, By: e.By, Text: e.Text} if e.Color != uno.Wild { v.Color = e.Color.String() } // A hand rides an event only if it is the viewer's own. The engine stamps every // seat's hand; this strips the rest. if e.Seat == viewer && e.Hand != nil { v.Hand = make([]unoCardView, 0, len(e.Hand)) for _, c := range e.Hand { v.Hand = append(v.Hand, viewUnoCard(c)) } } // A card rides an event only if it is a card played face up, or one the viewer // drew. A bot's (or another human's) drawn card never carries a face. if e.Card != nil { if e.Kind == uno.EvDraw || e.Kind == uno.EvForced { if e.Seat == viewer { c := viewUnoCard(*e.Card) v.Card = &c } } else { c := viewUnoCard(*e.Card) v.Card = &c } } out = append(out, v) } return out } // ---- sitting down ---------------------------------------------------------- // unoSeatRows mirrors the engine's seats into the storage rows that shadow them, // index for index. A human's staked is the buy-in that crossed the border; a // bot's is zero. func unoSeatRows(g uno.State, human string, buyIn int64) []storage.Seat { rows := make([]storage.Seat, len(g.Seats)) for i := range g.Seats { p := g.Seats[i] row := storage.Seat{Seat: i, Name: p.Name} if !p.Bot { row.MatrixUser = human row.Staked = buyIn } rows[i] = row } return rows } // handleUnoSit seats a player at a fresh table of their own or at an open chair on // somebody else's. func (s *Server) handleUnoSit(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var req struct { Tier string `json:"tier"` BuyIn int64 `json:"buyin"` Table string `json:"table"` Seat *int `json:"seat"` } if err := decodeJSON(r, &req); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if req.Table != "" { s.joinUno(w, r, user, req.Table, req.Seat, req.BuyIn) return } s.openUno(w, r, user, req.Tier, req.BuyIn) } // openUno opens a fresh table with the player in seat zero and bots in the rest — // the old solo flow, now a real shared table with no other humans on it yet. func (s *Server) openUno(w http.ResponseWriter, r *http.Request, user, tierSlug string, buyIn int64) { tier, err := uno.TierBySlug(tierSlug) if err != nil { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"}) return } if buyIn < tier.MinBuy || buyIn > tier.MaxBuy { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"}) return } name := s.displayName(r, user) seed1, seed2 := newSeeds() g, _, err := uno.New(tier, uno.TableSeats(tier, name, tier.Bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2) if err != nil { slog.Error("games: uno open", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } blob, err := json.Marshal(g) if err != nil { slog.Error("games: marshal new uno", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } id, err := storage.NewTableID() if err != nil { slog.Error("games: mint table id", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } t := storage.Table{ ID: id, Game: gameUno, Tier: tier.Slug, State: blob, Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo), } err = storage.OpenSoloTable(t, unoSeatRows(g, user, buyIn), buyIn) switch { case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount): writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"}) return case errors.Is(err, storage.ErrHandInProgress): writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"}) return case err != nil: slog.Error("games: open solo uno table", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } s.writeUnoTable(w, user, nil) } // pickOpenUnoSeat chooses a chair to join: the one asked for if it is a bot's, // otherwise the first bot seat. Returns -1 if there is nowhere to sit. func pickOpenUnoSeat(g uno.State, want *int) int { if want != nil { i := *want if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot { return i } return -1 } for i := range g.Seats { if g.Seats[i].Bot { return i } } return -1 } // joinUno sits a player at an open chair on an existing table, one transaction // under the table lock, so two people racing for the last seat cannot both win it. func (s *Server) joinUno(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) { name := s.displayName(r, user) var respErr error err := s.tableLocks.withTable(tableID, func() error { t, _, err := storage.LoadTable(tableID) if errors.Is(err, storage.ErrNoSuchTable) { respErr = storage.ErrNoSuchTable return nil } if err != nil { return err } if t.Game != gameUno { respErr = uno.ErrUnknownMove return nil } var g uno.State if err := json.Unmarshal(t.State, &g); err != nil { return err } if unoPlaying(g) { respErr = uno.ErrHandLive // you join between hands return nil } seat := pickOpenUnoSeat(g, wantSeat) if seat < 0 { respErr = uno.ErrTableFull return nil } if err := g.Occupy(seat, name, buyIn); err != nil { respErr = err return nil } blob, err := json.Marshal(g) if err != nil { return err } t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo) err = storage.SitDown(storage.Sit{ Table: t, Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn}, BuyIn: buyIn, }) switch { case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount): respErr = storage.ErrInsufficientChips return nil case errors.Is(err, storage.ErrHandInProgress): respErr = storage.ErrHandInProgress return nil case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable): respErr = storage.ErrSeatTaken return nil case err != nil: return err } s.publishTable(tableID) return nil }) if err != nil { slog.Error("games: join uno", "user", user, "table", tableID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if respErr != nil { writeJSONStatus(w, unoJoinStatus(respErr), map[string]string{"error": unoJoinMessage(respErr)}) return } s.writeUnoTable(w, user, nil) } func unoJoinStatus(err error) int { switch { case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, uno.ErrTableFull), errors.Is(err, storage.ErrSeatTaken), errors.Is(err, uno.ErrHandLive): return http.StatusConflict default: return http.StatusBadRequest } } func unoJoinMessage(err error) string { switch { case errors.Is(err, storage.ErrNoSuchTable): return "that table has closed" case errors.Is(err, uno.ErrTableFull), errors.Is(err, storage.ErrSeatTaken): return "that seat is taken" case errors.Is(err, uno.ErrHandLive): return "a hand is in play — sit down when it's over" case errors.Is(err, uno.ErrBadBuyIn): return "that isn't a legal buy-in for this table" case errors.Is(err, storage.ErrInsufficientChips): return "not enough chips to sit down" case errors.Is(err, storage.ErrHandInProgress): return "finish the game you're in first" default: return "you can't sit there" } } // ---- playing a hand -------------------------------------------------------- // handleUnoMove plays one move at the player's table: a hand move, or dealing the // next hand. Leaving is its own endpoint. func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var move uno.Move if err := decodeJSON(r, &move); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if move.Kind == uno.MoveLeave { s.leaveUno(w, user) return } tableID, seat, err := storage.PlayerSeat(user) if errors.Is(err, storage.ErrNoLiveHand) { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"}) return } if err != nil { slog.Error("games: uno move seat", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } var respErr error var respEvents []uno.Event err = s.tableLocks.withTable(tableID, func() error { t, seats, err := storage.LoadTable(tableID) if errors.Is(err, storage.ErrNoSuchTable) { respErr = storage.ErrNoSuchTable return nil } if err != nil { return err } var g uno.State if err := json.Unmarshal(t.State, &g); err != nil { return err } next, evs, aerr := uno.ApplyMove(g, seat, move) if aerr != nil { respErr = aerr return nil } // A solo session that just ended (the one human got up or busted at the deal) // is not a table any more: cash the seat out and close the felt. Only a solo // table reaches PhaseDone; a shared table plays on. if next.Phase == uno.PhaseDone { if err := s.settleUnoLeave(t, next, seat, user); err != nil { if errors.Is(err, storage.ErrStaleTable) { respErr = storage.ErrStaleTable return nil } return err } respEvents = evs s.publishTable(tableID) return nil } st, err := unoStep(next, evs, seats) if err != nil { return err } acting := seats[seat] acting.Away = false acting.LastSeen = time.Now().Unix() t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline if err := storage.CommitTable(storage.TableCommit{ Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit, }); err != nil { if errors.Is(err, storage.ErrStaleTable) { respErr = storage.ErrStaleTable return nil } return err } respEvents = evs s.publishTable(tableID) return nil }) if err != nil { slog.Error("games: uno move", "user", user, "table", tableID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if respErr != nil { writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)}) return } s.writeUnoTable(w, user, respEvents) } func unoMoveStatus(err error) int { switch { case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable): return http.StatusConflict default: return http.StatusBadRequest } } func unoMoveMessage(err error) string { switch { case errors.Is(err, storage.ErrStaleTable): return "the table moved on — take another look" case errors.Is(err, storage.ErrNoSuchTable): return "that table has closed" case errors.Is(err, uno.ErrHandLive): return "finish the hand first" case errors.Is(err, uno.ErrNoHand): return "there's no hand in play — deal one" case errors.Is(err, uno.ErrNotYourTurn): return "it isn't your turn" case errors.Is(err, uno.ErrCantPlay): return "that card doesn't go on this one" case errors.Is(err, uno.ErrNeedColor): return "pick a colour for the wild" case errors.Is(err, uno.ErrMustPlayNow): return "play the card you drew, or pass" case errors.Is(err, uno.ErrCantPass): return "draw first, then you can pass" case errors.Is(err, uno.ErrMustStack): return "answer the stack with a draw card, or take it" case errors.Is(err, uno.ErrNoStack): return "there's nothing pointed at you to take" case errors.Is(err, uno.ErrNoCatch): return "there's nobody in that seat to catch" default: return "that move isn't legal here" } } // ---- getting up ------------------------------------------------------------ // handleUnoLeave is the get-up endpoint. Leaving is its own route because it is a // storage operation, not an engine move — the chips cross the border and the felt // may close. func (s *Server) handleUnoLeave(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } s.leaveUno(w, user) } // leaveUno gets a player up from their table, turning what is in front of them // back into chips. It refuses mid-hand and closes the felt behind the last human. func (s *Server) leaveUno(w http.ResponseWriter, user string) { tableID, seat, err := storage.PlayerSeat(user) if errors.Is(err, storage.ErrNoLiveHand) { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"}) return } if err != nil { slog.Error("games: uno leave seat", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } var respErr error err = s.tableLocks.withTable(tableID, func() error { t, _, err := storage.LoadTable(tableID) if errors.Is(err, storage.ErrNoSuchTable) { respErr = storage.ErrNoSuchTable return nil } if err != nil { return err } var g uno.State if err := json.Unmarshal(t.State, &g); err != nil { return err } if unoPlaying(g) { respErr = uno.ErrHandLive return nil } if err := s.settleUnoLeave(t, g, seat, user); err != nil { if errors.Is(err, storage.ErrStaleTable) { respErr = storage.ErrStaleTable return nil } return err } s.publishTable(tableID) return nil }) if err != nil { slog.Error("games: uno leave", "user", user, "table", tableID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if respErr != nil { writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)}) return } s.writeUnoTable(w, user, nil) } // settleUnoLeave vacates a seat, credits the stack home, and closes the table if // nobody human is left — all inside the caller's lock. func (s *Server) settleUnoLeave(t storage.Table, g uno.State, seat int, user string) error { home, err := g.Vacate(seat) if err != nil { return err } blob, err := json.Marshal(g) if err != nil { return err } t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0 if err := storage.LeaveTable(storage.Leave{ Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home, }); err != nil { return err } return storage.CloseTable(t.ID) } // ---- the response ---------------------------------------------------------- // writeUnoTable answers with the whole page state — the money and the table as the // player's own seat may see it — plus, when a move produced one, the redacted event // script for that seat to animate. func (s *Server) writeUnoTable(w http.ResponseWriter, user string, evs []uno.Event) { v, err := s.table(user) if err != nil { slog.Error("games: uno table", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if len(evs) > 0 { if _, seat, serr := storage.PlayerSeat(user); serr == nil { v.UnoEvents = viewUnoEvents(evs, seat) } } writeJSON(w, v) }