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) } }