games: UNO becomes a table you sit at, and the pot that pays whoever goes out first
Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.
The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.
The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
@@ -4,57 +4,54 @@ 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) {
|
||||
// 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, 1000)
|
||||
fund(t, 5000)
|
||||
|
||||
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 100, "tier": "full"}))
|
||||
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("start = %d, want 200", code)
|
||||
t.Fatalf("sit = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 900 {
|
||||
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||
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("start returned no uno game: game=%q", v.Game)
|
||||
t.Fatalf("sit returned no table: 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 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("a full house is four seats, got %d", len(g.Seats))
|
||||
t.Fatalf("three bots and you 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.Phase != "handover" {
|
||||
t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase)
|
||||
}
|
||||
if g.Top.Value == "" {
|
||||
t.Error("no card in play")
|
||||
|
||||
// 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 g.Turn != 0 {
|
||||
t.Errorf("you play first, turn is %d", g.Turn)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +59,16 @@ func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
|
||||
// back never carries a bot's drawn card.
|
||||
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
fund(t, 5000)
|
||||
|
||||
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")
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -93,110 +90,144 @@ func TestUnoMovePlaysTheWholeLap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
for i, c := range v.Uno.Hand {
|
||||
if c.Wild {
|
||||
wild = i
|
||||
break
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
assertUnoRedacted(t, g, stepEvs)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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.
|
||||
// Bury seat 2 the way the engine does: no cards, and out.
|
||||
g.Hands[2] = nil
|
||||
g.Out[2] = true
|
||||
|
||||
v := viewUno(g)
|
||||
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 == 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)
|
||||
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. 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.
|
||||
// has to reach the browser.
|
||||
func TestTheStackBillCrossesTheWire(t *testing.T) {
|
||||
g, _, err := uno.New(100, mustTier(t, "nm-duel"), 0.05, 3, 4)
|
||||
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)
|
||||
v := viewUno(g, 0)
|
||||
if v.Pending != 6 {
|
||||
t.Errorf("the felt was told the bill is %d, want 6", v.Pending)
|
||||
}
|
||||
@@ -204,12 +235,3 @@ func TestTheStackBillCrossesTheWire(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user