package web import ( "encoding/json" "errors" "log/slog" "net/http" "pete/internal/games/blackjack" "pete/internal/games/uno" "pete/internal/storage" ) // UNO, played for chips against bots. // // The seam is the same as every other table, but there is one thing here that no // other table has: opponents. The obvious way to give a browser opponents is a // socket, and the plan says solo UNO must not need one — so it doesn't. A move // goes up, and what comes back is the player's move *plus every bot turn it // handed off to*, as a script of events. One request, one round of the table. // // What the browser is allowed to see: its own hand, the card in play, the colour // in play, and how many cards each bot is holding. Not the deck, not a bot's // hand, not even the face of a card a bot drew. That last one is most of the // deck, and it is the thing that would turn a game of counting cards into a game // of reading the network tab. // unoCardView is one card, ready to draw. The browser gets the colour and the // face as words, not as the engine's integers — the same bargain the blackjack // table makes, and for the same reason: the browser draws faces, not logic. type unoCardView struct { Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild" Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4" Wild bool `json:"wild"` // it's a wild, whatever colour it was played as } 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, and a number of cards. A bot's // cards are a *count*. There is no field here for what they are. type unoSeatView struct { Name string `json:"name"` Cards int `json:"cards"` You bool `json:"you"` Uno bool `json:"uno"` // down to one card Called bool `json:"called"` // …and said so. A seat where Uno is true and this isn't is a seat you can catch Out bool `json:"out"` // No Mercy: buried at twenty-five, and not coming back } // unoView is a game as its player may see it. type unoView struct { Tier uno.Tier `json:"tier"` 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 // The UNO call. UnoAt is which of your cards would leave you holding exactly // one if you played it — the table asks for the call on those, and it asks the // engine which they are because No Mercy's "discard all" makes counting the // hand the wrong answer. Catchable is which seats are sitting on one card they // never announced. UnoAt []int `json:"uno_at"` Catchable []int `json:"catchable"` Turn int `json:"turn"` Dir int `json:"dir"` // No Mercy: the bill a stack of draw cards has run up. Whoever stops stacking // pays it, and while it stands it is the only thing on the table that matters — // so the felt has to be able to say what it is. Pending int `json:"pending"` Bet int64 `json:"bet"` Pays int64 `json:"pays"` // what going out right now would actually pay Phase string `json:"phase"` Outcome string `json:"outcome,omitempty"` Winner int `json:"winner"` Payout int64 `json:"payout,omitempty"` Rake int64 `json:"rake,omitempty"` Net int64 `json:"net"` } func viewUno(g uno.State) unoView { v := unoView{ Tier: g.Tier, Top: viewUnoCard(g.Top()), Color: g.Color.String(), Deck: g.Left(), Turn: g.Turn, Dir: g.Dir, Pending: g.Pending, Bet: g.Bet, Pays: g.Pays(), Phase: string(g.Phase), Outcome: string(g.Outcome), Winner: -1, Payout: g.Payout, Rake: g.Rake, Net: g.Net(), } // An empty hand is a seat that went out — *unless* the mercy rule took it, in // which case an empty hand is a grave. Those two look identical from the count // alone, which is why a buried seat is asked about rather than inferred. for i, n := range g.Counts() { live := g.Live(i) seat := unoSeatView{ Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live, Called: i < len(g.Called) && g.Called[i], } if i == uno.You { seat.Name = "You" } else if i-1 < len(g.Bots) { seat.Name = g.Bots[i-1] } v.Seats = append(v.Seats, seat) if live && n == 0 { v.Winner = i } } // And you can win a No Mercy table without ever going out: outlive it, and the // last seat standing is you, with a hand still in it. if v.Winner < 0 && g.Outcome.Won() { v.Winner = uno.You } for _, c := range g.Hands[uno.You] { v.Hand = append(v.Hand, viewUnoCard(c)) } v.Playable = g.Playable() if v.Playable == nil { v.Playable = []int{} } // Empty arrays, never null: the table indexes into these, and `null.indexOf` // is a broken game rather than a quiet no-op. v.UnoAt = g.UnoAt() if v.UnoAt == nil { v.UnoAt = []int{} } v.Catchable = g.Catchable() if v.Catchable == nil { v.Catchable = []int{} } return v } // unoEventView is one beat of the script the table plays back: a card going // down, a seat eating a +4, the turn coming round. The engine's own events carry // engine types, so they are re-rendered here rather than shipped raw — and this // is also the wall where a bot's drawn card is dropped on the floor. 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"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw By int `json:"by"` // who did the catching. Seat zero is you, and "you" is a real answer, so never omitempty either Text string `json:"text,omitempty"` // Hand is your hand as it stands after this event, on the events that changed // it. The table redraws your fan from this as the lap plays back — see the // engine's Event.Hand for why. It is your own hand, so there is nothing here // the browser wasn't already holding. Hand []unoCardView `json:"hand,omitempty"` } func viewUnoEvents(evs []uno.Event) []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() } // The engine only stamps a hand on an event about seat zero. This is the belt // to that brace: whatever the engine thinks it's doing, no hand but yours // leaves this building. if e.Seat == uno.You && e.Hand != nil { v.Hand = make([]unoCardView, 0, len(e.Hand)) for _, c := range e.Hand { v.Hand = append(v.Hand, viewUnoCard(c)) } } if e.Card != nil { // The engine only ever attaches a card to an event the seat is entitled // to see it in — a card played face up, or one *you* drew. This check is // the belt to that pair of braces: a bot's draw never carries a face, // whatever the engine thinks it's doing. if e.Kind == uno.EvDraw || e.Kind == uno.EvForced { if e.Seat == uno.You { c := viewUnoCard(*e.Card) v.Card = &c } } else { c := viewUnoCard(*e.Card) v.Card = &c } } out = append(out, v) } return out } // handleUnoStart takes the bet and deals. Same order as every other table: the // chips are staked first, in the same statement that checks they exist, so two // deals fired at once cannot bet the same chip. func (s *Server) handleUnoStart(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var req struct { Bet int64 `json:"bet"` Tier string `json:"tier"` } if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"}) return } tier, err := uno.TierBySlug(req.Tier) if err != nil { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"}) return } if err := storage.Stake(user, req.Bet); err != nil { if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"}) return } slog.Error("games: uno stake", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } seed1, seed2 := newSeeds() g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2) if err != nil { // The game never happened, so the stake never should have left. _ = storage.Award(user, req.Bet) slog.Error("games: uno deal", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } s.persistUno(w, user, g, evs, seed1, seed2, true) } // handleUnoMove plays one turn: a card, a draw, or passing on the card you drew. // The bots' turns come back with it. 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 } live, err := storage.LoadLiveHand(user) if errors.Is(err, storage.ErrNoLiveHand) { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"}) return } if err != nil { slog.Error("games: uno load", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if live.Game != gameUno { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"}) return } var g uno.State if err := json.Unmarshal(live.State, &g); err != nil { slog.Error("games: unreadable uno game", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } next, evs, err := uno.ApplyMove(g, move) if err != nil { // The refusals a player can actually cause, said in words rather than as // "that move isn't legal here" — which, in a game with this many rules, is // the table refusing to explain itself. msg := "that move isn't legal here" switch { case errors.Is(err, uno.ErrCantPlay): msg = "that card doesn't go on this one" case errors.Is(err, uno.ErrNeedColor): msg = "pick a colour for the wild" case errors.Is(err, uno.ErrMustPlayNow): msg = "play the card you drew, or pass" case errors.Is(err, uno.ErrCantPass): msg = "draw first, then you can pass" case errors.Is(err, uno.ErrMustStack): msg = "answer the stack with a draw card, or take it" case errors.Is(err, uno.ErrNoStack): msg = "there's nothing pointed at you to take" case errors.Is(err, uno.ErrNoCatch): msg = "there's nobody in that seat to catch" } writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg}) return } s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false) } // persistUno writes the game back and answers the browser. func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) { blob, err := json.Marshal(g) if err != nil { slog.Error("games: marshal uno", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } done := g.Phase == uno.PhaseDone v, ok := s.commit(w, user, finished{ Game: gameUno, Blob: blob, Bet: g.Bet, Payout: g.Payout, Rake: g.Rake, Outcome: string(g.Outcome), Done: done, Seed1: seed1, Seed2: seed2, Fresh: fresh, }) if !ok { return } // A finished game is gone from storage, so the table has none to show — but the // browser still needs the final board to land the verdict on. if done { uv := viewUno(g) v.Uno = &uv } v.UnoEvents = viewUnoEvents(evs) writeJSON(w, v) }