package web import ( "testing" "pete/internal/games/uno" ) // Sitting down is the only time chips leave your stack at this table, and getting // up is the only time they come back. The ante and the pot move inside the engine. func TestUnoSitTakesTheBuyIn(t *testing.T) { s := newCasino(t) fund(t, 5000) v, code := call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit", map[string]any{"tier": "full", "buyin": 1000})) if code != 200 { t.Fatalf("sit = %d, want 200", code) } if v.Chips != 4000 { t.Fatalf("chips after a 1000 buy-in = %d, want 4000", v.Chips) } if v.Game != gameUno || v.Uno == nil { t.Fatalf("sit returned no table: game=%q", v.Game) } g := v.Uno if g.Stack != 1000 { t.Errorf("you sat down with %d, want the 1000 you bought in for", g.Stack) } if len(g.Seats) != 4 { t.Fatalf("three bots and you is four seats, got %d", len(g.Seats)) } if g.Phase != "handover" { t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase) } // Deal a hand: chips do not move (the ante is within the blob), and you are dealt // seven cards and to act. deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move", map[string]any{"kind": "deal"})) if code != 200 { t.Fatalf("deal = %d, want 200", code) } if deal.Chips != 4000 { t.Errorf("chips moved on the deal: %d, want 4000 — the ante is inside the engine", deal.Chips) } if deal.Uno == nil || len(deal.Uno.Hand) != uno.HandSize { t.Fatalf("you were dealt the wrong hand: %+v", deal.Uno) } if deal.Uno.Turn != 0 { t.Errorf("you act first at your own table, turn is %d", deal.Uno.Turn) } if deal.Uno.Pot != deal.Uno.Ante*int64(len(deal.Uno.Seats)) { t.Errorf("pot is %d, want one ante per seat", deal.Uno.Pot) } } // A move plays your turn and every bot turn behind it, and the script that comes // back never carries a bot's drawn card. func TestUnoMovePlaysTheWholeLap(t *testing.T) { s := newCasino(t) fund(t, 5000) call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit", map[string]any{"tier": "table", "buyin": 1000})) deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move", map[string]any{"kind": "deal"})) if code != 200 || deal.Uno == nil { t.Fatalf("deal = %d", code) } v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move", map[string]any{"kind": "draw"})) if code != 200 { t.Fatalf("draw = %d, want 200", code) } if v.Uno == nil { t.Fatal("the move returned no game") } if len(v.UnoEvents) == 0 { t.Fatal("a move that happened sent back no events") } if v.Uno.Phase != "drawn" && v.Uno.Turn != 0 { t.Errorf("the bots should have played back round to you, turn is %d", v.Uno.Turn) } for _, e := range v.UnoEvents { if (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != 0 && e.Card != nil { t.Fatalf("a bot's drawn card crossed the wire: %+v", e) } } } // TestUnoViewNeverLeaksAnotherSeatsCards is the security boundary of the whole // multiplayer table. There is no showdown in UNO, so the rule is absolute: no // seat's hand, and no card a seat drew, ever reaches another viewer. Rendered for // every seat, after every step of a hand played to its end. // // After SSE the view a seat renders fans to that seat's stream, so a single missed // redaction broadcasts a hand to the whole felt. The engine emits every seat's // cards (it cannot know who a shared stream is for), which makes viewUno and // viewUnoEvents the only thing between the deck and the network tab. func TestUnoViewNeverLeaksAnotherSeatsCards(t *testing.T) { tier, _ := uno.TierBySlug("full") // Two humans (0, 2) and two bots (1, 3), so the test covers a viewer seeing both // another human and a bot, and a human at a non-zero index. seats := []uno.SeatConfig{ {Name: "Ana", Stack: 2000}, {Name: "Bot A", Bot: true, Stack: 2000}, {Name: "Bo", Stack: 2000}, {Name: "Bot B", Bot: true, Stack: 2000}, } g, _, err := uno.New(tier, seats, tier.RakePct, 5, 6) if err != nil { t.Fatal(err) } g, evs, err := uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal}) if err != nil { t.Fatal(err) } assertUnoRedacted(t, g, evs) for step := 0; unoPlaying(g) && step < 200; step++ { seat := g.Turn if g.Seats[seat].Bot { t.Fatalf("advance stopped on a bot at seat %d", seat) } move := unoHumanMove(g, seat) var stepEvs []uno.Event g, stepEvs, err = uno.ApplyMove(g, seat, move) if err != nil { t.Fatalf("seat %d %s: %v", seat, move.Kind, err) } assertUnoRedacted(t, g, stepEvs) } } // unoHumanMove picks a legal move for the human to act: play a legal card, take a // stack you can't answer, pass a drawn card, otherwise draw. func unoHumanMove(g uno.State, seat int) uno.Move { if p := g.Playable(seat); len(p) > 0 { m := uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red} for _, at := range g.UnoAt(seat) { if at == p[0] { m.Uno = true } } return m } switch g.Phase { case uno.PhaseStack: return uno.Move{Kind: uno.MoveTake} case uno.PhaseDrawn: return uno.Move{Kind: uno.MovePass} default: return uno.Move{Kind: uno.MoveDraw} } } // assertUnoRedacted renders every seat's view and event script and fails if any of // them carries a card belonging to another seat. func assertUnoRedacted(t *testing.T, g uno.State, evs []uno.Event) { t.Helper() for viewer := range g.Seats { v := viewUno(g, viewer) // The view's hand is the viewer's own, exactly — no more, no fewer. if len(v.Hand) != len(g.Hands[viewer]) { t.Fatalf("seat %d's view carries %d cards, but the seat holds %d", viewer, len(v.Hand), len(g.Hands[viewer])) } // The event script: a hand rides only the viewer's own beat, and a drawn card // only the viewer's own draw. (A card a bot plays is public — it lands on the // pile — so only a *drawn* card is a secret, which is why this is scoped to the // draw and forced beats.) Undo either guard in viewUnoEvents and this fails. for _, e := range viewUnoEvents(evs, viewer) { if len(e.Hand) > 0 && e.Seat != viewer { t.Fatalf("seat %d's event stream carries seat %d's hand", viewer, e.Seat) } if e.Card != nil && (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != viewer { t.Fatalf("seat %d's event stream carries seat %d's drawn card", viewer, e.Seat) } } } } // A seat the mercy rule has buried holds no cards; so does one that just went out. // The view tells them apart by asking the engine (Live), not by inferring from a // count of zero. func TestABuriedSeatIsRenderedOut(t *testing.T) { tier, _ := uno.TierBySlug("nm-full") g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 7, 9) if err != nil { t.Fatal(err) } g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal}) if err != nil { t.Fatal(err) } // Bury seat 2 the way the engine does: no cards, and out. g.Hands[2] = nil g.Out[2] = true v := viewUno(g, 0) if !v.Seats[2].Out { t.Error("a buried seat must say so; the felt draws it as a grave") } if v.Seats[2].Uno { t.Error("a buried seat is not one card away from going out") } if v.Winner != -1 { t.Errorf("nobody has taken the pot mid-hand, so there is no winner: got %d", v.Winner) } } // The bill a stack has run up is the only thing on the table while it stands, so it // has to reach the browser. func TestTheStackBillCrossesTheWire(t *testing.T) { tier, _ := uno.TierBySlug("nm-duel") g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 3, 4) if err != nil { t.Fatal(err) } g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal}) if err != nil { t.Fatal(err) } g.Pending = 6 g.Phase = uno.PhaseStack v := viewUno(g, 0) if v.Pending != 6 { t.Errorf("the felt was told the bill is %d, want 6", v.Pending) } if v.Phase != string(uno.PhaseStack) { t.Errorf("phase = %q, want %q", v.Phase, uno.PhaseStack) } }