Files
Pete/internal/web/games_uno_test.go
prosolis 8db8845feb games: no mercy on the felt, and the bill that went to the wrong window
The engine has been able to play No Mercy since aca523e. Now a browser can.

The switch is a switch, not a fourth table: the tier is still the table size,
because that is what you are paid for, and the deck is the other dial. Six faces
the normal box does not print, sized by the card's own vars and never by the box
they sit in. The stack says what the bill is on the felt, in the turn line and on
the button, and under it the deck is dead — you cannot draw your way out of a
bill somebody has run up and pointed at you.

The wild draws glow. That started as decoration and turned out to be doing work:
No Mercy prints a coloured +4 right beside the wild one, and in a hand of twenty
the glow is what tells them apart.

A buried seat is not an empty one, which is the whole trap here — a seat killed
at twenty-five holds no cards, and neither does a seat that just went out and
won. The view asks the engine which it is instead of counting to zero, so the
winner is never the corpse.

Two bugs, both found in a browser and neither findable anywhere else:

The felt's stack bill was writing into the chip bar. It was [data-pending], and
so is the bar's "your chips are still coming" readout — and the bar lives inside
the table's own root and comes first in the document. A stack quietly overwrote
the escrow message and never appeared on the felt at all. A table's attributes
are not a private namespace.

And hold'em, re-driven on the 20M-hand policy (six hands, got up 61 ahead of a
100 buy-in, money conserved to the chip — Phase 4 closed), let you click a button
that did nothing: Deal, Leave and Top up stayed alive through the whole deal
animation, where send() drops the click on purpose. The lock is on the buttons
now, not only in the variable.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 11:10:07 -07:00

216 lines
6.9 KiB
Go

package web
import (
"testing"
"pete/internal/games/uno"
"pete/internal/storage"
)
// The one thing this table cannot get wrong: the stake leaves the stack, and no
// card a player is not entitled to see leaves the server. At UNO that is not one
// hole card — it is the deck and every bot's hand, which is most of the game.
func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "full"}))
if code != 200 {
t.Fatalf("start = %d, want 200", code)
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
}
if v.Game != gameUno || v.Uno == nil {
t.Fatalf("start returned no uno game: game=%q", v.Game)
}
g := v.Uno
if len(g.Hand) != uno.HandSize {
t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize)
}
if len(g.Seats) != 4 {
t.Fatalf("a full house is four seats, got %d", len(g.Seats))
}
// A bot is a name and a number. There is no field here that could carry a
// card, which is the point — this is a compile-time guarantee, and the test
// exists to make deleting it loud.
for i, seat := range g.Seats {
if i == 0 {
if !seat.You || seat.Name != "You" {
t.Errorf("seat 0 should be you: %+v", seat)
}
continue
}
if seat.You || seat.Name == "" {
t.Errorf("seat %d should be a named bot: %+v", i, seat)
}
if seat.Cards != uno.HandSize {
t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize)
}
}
if g.Top.Value == "" {
t.Error("no card in play")
}
if g.Turn != 0 {
t.Errorf("you play first, turn is %d", g.Turn)
}
}
// 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, 1000)
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "table"}))
if v.Uno == nil {
t.Fatal("no game")
}
// Draw, which is legal from any hand: the turn passes to the bots and comes
// back, unless the card drawn happens to be playable.
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)
}
}
}
// You cannot play a wild without naming a colour — and the zero value of the
// colour field is not red, so a move that simply omits it is refused rather than
// quietly played as one.
func TestUnoWildWithNoColourIsRefused(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
// Deal until a wild lands in the opening hand: it's four cards in 108, so it
// doesn't take long, and this is the only way to get one without rigging the
// deck through a door the server doesn't have.
var wild = -1
for try := 0; try < 40 && wild < 0; try++ {
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 10, "tier": "duel"}))
if v.Uno == nil {
t.Fatal("no game")
}
for i, c := range v.Uno.Hand {
if c.Wild {
wild = i
break
}
}
if wild < 0 {
// Abandon this deal and take another. The live row is keyed on the
// player, so it has to go before the next start can be seated.
if err := storage.ClearLiveHand(testPlayer); err != nil {
t.Fatal(err)
}
}
}
if wild < 0 {
t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure")
}
_, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild}))
if code != 400 {
t.Fatalf("a wild with no colour = %d, want 400", code)
}
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)}))
if code != 200 {
t.Fatalf("a wild played as green = %d, want 200", code)
}
if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" {
// The bots have played since, so the colour may have moved on — what must
// not happen is the wild going down as anything we didn't ask for.
t.Logf("colour in play after the bots: %s", v.Uno.Color)
}
}
// A seat the mercy rule has buried holds no cards. So does a seat that has just
// gone out and won. The view has to tell them apart, because everything it says
// afterwards hangs off it: who won, whose fan of cards to rub out, and whether the
// player is looking at a payout or a grave.
//
// This is the whole reason the view asks the engine (Live) instead of inferring it
// from a count of zero.
func TestABuriedSeatIsNotTheWinner(t *testing.T) {
g, _, err := uno.New(100, mustTier(t, "nm-full"), 0.05, 7, 9)
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)
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 == 2 {
t.Fatal("the buried seat was reported as the winner — an empty hand is not a win")
}
if v.Winner != -1 {
t.Errorf("nobody has gone out yet, so there is no winner: got %d", v.Winner)
}
// And the other ending only this deck has: you win by outliving the table, with
// a hand still in front of you.
g.Phase, g.Outcome, g.Payout = uno.PhaseDone, uno.OutcomeWon, g.Pays()
if v := viewUno(g); v.Winner != uno.You {
t.Errorf("you outlived the table; winner = %d, want you (%d)", v.Winner, uno.You)
}
}
// The bill a stack has run up is the only thing on the table while it stands, so it
// has to reach the browser. It is a No Mercy field on a struct the normal game
// shares, which is exactly the kind of field that quietly never gets copied across.
func TestTheStackBillCrossesTheWire(t *testing.T) {
g, _, err := uno.New(100, mustTier(t, "nm-duel"), 0.05, 3, 4)
if err != nil {
t.Fatal(err)
}
g.Pending = 6
g.Phase = uno.PhaseStack
v := viewUno(g)
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)
}
}
func mustTier(t *testing.T, slug string) uno.Tier {
t.Helper()
tier, err := uno.TierBySlug(slug)
if err != nil {
t.Fatalf("no tier %q: %v", slug, err)
}
return tier
}