From 927ed8416359901dd8f640d8c3dc34e0f4f832e8 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:37:51 -0700 Subject: [PATCH] games: UNO becomes a table you sit at, and the pot that pays whoever goes out first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/games/uno/bot.go | 14 +- internal/games/uno/call.go | 50 +- internal/games/uno/call_test.go | 48 +- internal/games/uno/nomercy.go | 38 +- internal/games/uno/nomercy_test.go | 226 +++---- internal/games/uno/uno.go | 928 ++++++++++++++++------------- internal/games/uno/uno_test.go | 444 +++++++------- internal/web/games_pages.go | 13 +- internal/web/games_play.go | 26 +- internal/web/games_table.go | 26 +- internal/web/games_uno.go | 727 ++++++++++++++++------ internal/web/games_uno_table.go | 187 ++++++ internal/web/games_uno_test.go | 272 +++++---- internal/web/server.go | 2 +- internal/web/templates/uno.html | 6 +- 15 files changed, 1799 insertions(+), 1208 deletions(-) create mode 100644 internal/web/games_uno_table.go diff --git a/internal/games/uno/bot.go b/internal/games/uno/bot.go index b1d1841..56e99a8 100644 --- a/internal/games/uno/bot.go +++ b/internal/games/uno/bot.go @@ -173,17 +173,9 @@ func botColor(hand []Card, rng *rand.Rand) Color { return best } -// botNames deals the bots their names. Flavour, and load-bearing flavour: "Kiwi -// played a +4" is a table, "Bot 2 played a +4" is a test fixture. -func botNames(n int, rng *rand.Rand) []string { - pool := append([]string(nil), botPool...) - rng.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] }) - if n > len(pool) { - n = len(pool) - } - return pool[:n] -} - +// botPool are the regulars a bot seat is named from. Flavour, and load-bearing +// flavour: "Kiwi played a +4" is a table, "Bot 2 played a +4" is a test fixture. +// More names than a full table, so no two chairs ever share one. var botPool = []string{ "Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan", "Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine", diff --git a/internal/games/uno/call.go b/internal/games/uno/call.go index ea72701..5a1cd03 100644 --- a/internal/games/uno/call.go +++ b/internal/games/uno/call.go @@ -49,7 +49,7 @@ func botForgets(rng *rand.Rand) bool { return rng.Float64() < botForget } // — announces it. A seat on any other number of cards owes nothing and this does // nothing, which is what makes it safe to call after every play. func (s *State) declare(seat int, called bool, evs *[]Event) { - if s.Phase == PhaseDone || len(s.Hands[seat]) != 1 { + if !s.playing() || len(s.Hands[seat]) != 1 { return } s.ensureCalled() @@ -59,43 +59,45 @@ func (s *State) declare(seat int, called bool, evs *[]Event) { } } -// botsCatch is the window. You have just played, you are holding one card, and -// you didn't say the word — so every bot still in the game gets one look, in seat -// order, and the first to see it takes you for two. +// botsCatch is the window. The seat that just played is holding one card and +// didn't say the word — so every bot still in the hand gets one look, in seat +// order, and the first to see it takes them for two. // // It runs before runBots on purpose. The catch has to land while the table is -// still looking at the card you played, not three turns later. -func (s *State) botsCatch(evs *[]Event, rng *rand.Rand) { - if s.Phase == PhaseDone || len(s.Hands[You]) != 1 || s.called(You) { +// still looking at the card that was played, not three turns later. Only the bots +// catch on a roll here; a human polices the table with the catch button, which is +// the asymmetry the whole rule turns on — attention is the player's edge. +func (s *State) botsCatch(actor int, evs *[]Event, rng *rand.Rand) { + if !s.playing() || len(s.Hands[actor]) != 1 || s.called(actor) { return } for _, seat := range s.alive() { - if seat == You { - continue + if seat == actor || !s.Seats[seat].Bot { + continue // a human catches with the button, not on a roll } if rng.Float64() >= botAlert { continue // this one wasn't looking } - s.penalise(You, seat, EvCaught, evs, rng) + s.penalise(actor, seat, EvCaught, evs, rng) return // caught once is caught. They don't queue up to take turns at you } } -// playerCatches calls out a seat you think is holding one card in silence. +// seatCatches calls out a seat the caller thinks is holding one card in silence. // -// It is not a turn: right or wrong, the turn stays where it was, which is with -// you. What it costs is the risk — a seat that did call, or isn't on one card at -// all, is a seat you have just accused of nothing, and that is two cards to you. -func (s *State) playerCatches(m Move, rng *rand.Rand) ([]Event, error) { +// It is not a turn: right or wrong, the turn stays where it was. What it costs is +// the risk — a seat that did call, or isn't on one card at all, is a seat the +// caller has accused of nothing, and that is two cards to them. +func (s *State) seatCatches(caller int, m Move, rng *rand.Rand) ([]Event, error) { seat := m.Seat - if seat == You || seat < 0 || seat >= len(s.Hands) || !s.live(seat) { + if seat == caller || seat < 0 || seat >= len(s.Hands) || !s.live(seat) { return nil, ErrNoCatch } var evs []Event if len(s.Hands[seat]) == 1 && !s.called(seat) { - s.penalise(seat, You, EvCaught, &evs, rng) // got them + s.penalise(seat, caller, EvCaught, &evs, rng) // got them } else { - s.penalise(You, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking + s.penalise(caller, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking } return evs, nil } @@ -127,11 +129,11 @@ func (s *State) penalise(victim, by int, kind string, evs *[]Event, rng *rand.Ra // // It answers for every card, legal or not. The table only ever asks about a card // you were allowed to play anyway. -func (s State) UnoAt() []int { - if s.Phase == PhaseDone || s.Turn != You { +func (s State) UnoAt(seat int) []int { + if !s.playing() || s.Turn != seat { return nil } - hand := s.Hands[You] + hand := s.Hands[seat] var out []int for i, c := range hand { left := len(hand) - 1 @@ -156,13 +158,13 @@ func (s State) UnoAt() []int { // already isn't — this is the same two facts, subtracted, and doing that // subtraction on the server is only so that the rule for what counts as catchable // lives in one place instead of two. -func (s State) Catchable() []int { - if s.Phase == PhaseDone || s.Turn != You { +func (s State) Catchable(viewer int) []int { + if !s.playing() || s.Turn != viewer { return nil // you can only catch a seat on your own turn } var out []int for _, seat := range s.alive() { - if seat != You && len(s.Hands[seat]) == 1 && !s.called(seat) { + if seat != viewer && len(s.Hands[seat]) == 1 && !s.called(seat) { out = append(out, seat) } } diff --git a/internal/games/uno/call_test.go b/internal/games/uno/call_test.go index 2d6995e..4758a9f 100644 --- a/internal/games/uno/call_test.go +++ b/internal/games/uno/call_test.go @@ -14,7 +14,7 @@ import "testing" // a +2. func oneCardAway(t *testing.T, seed uint64) State { t.Helper() - s := deal(t, duel(), 100, seed) + s := deal(t, duel(), seed) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}, {Red, Two}} @@ -33,7 +33,7 @@ func oneCardAway(t *testing.T, seed uint64) State { func TestCallingUnoKeepsYouSafe(t *testing.T) { for seed := uint64(0); seed < 200; seed++ { s := oneCardAway(t, seed) - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true}) if err != nil { t.Fatalf("seed %d: play: %v", seed, err) } @@ -56,7 +56,7 @@ func TestForgettingUnoGetsYouCaught(t *testing.T) { caught, games := 0, 400 for seed := uint64(0); seed < uint64(games); seed++ { s := oneCardAway(t, seed) - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("seed %d: play: %v", seed, err) } @@ -83,13 +83,13 @@ func TestForgettingUnoGetsYouCaught(t *testing.T) { // forgetting at a full table is very nearly always punished. func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) { away := func(tier Tier, seed uint64) bool { - s := deal(t, tier, 100, seed) + s := deal(t, tier, seed) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}, {Red, Two}} s.Turn = You s.Phase = PhasePlay - _, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + _, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play: %v", err) } @@ -114,7 +114,7 @@ func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) { // quietBot puts a bot on one card it never called, with the turn back on you. func quietBot(t *testing.T, called bool) State { t.Helper() - s := deal(t, duel(), 100, 21) + s := deal(t, duel(), 21) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}} @@ -131,7 +131,7 @@ func TestCatchingAQuietBot(t *testing.T) { s := quietBot(t, false) before := total(census(s)) - next, evs, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: 1}) + next, evs, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: 1}) if err != nil { t.Fatalf("catch: %v", err) } @@ -167,7 +167,7 @@ func TestCatchingACleanBotCostsYou(t *testing.T) { }()}, } { t.Run(tc.name, func(t *testing.T) { - next, evs, err := ApplyMove(tc.state, Move{Kind: MoveCatch, Seat: 1}) + next, evs, err := ApplyMove(tc.state, You, Move{Kind: MoveCatch, Seat: 1}) if err != nil { t.Fatalf("catch: %v", err) } @@ -188,7 +188,7 @@ func TestCatchingACleanBotCostsYou(t *testing.T) { func TestYouCannotCatchYourself(t *testing.T) { s := quietBot(t, false) for _, seat := range []int{You, -1, 9} { - if _, _, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch { + if _, _, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch { t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err) } } @@ -198,7 +198,7 @@ func TestYouCannotCatchYourself(t *testing.T) { // your way back down to one: that is a new call you owe, not the old one still // standing. Without this a seat could be caught out once and never again. func TestACallIsSpentWhenTheHandGrows(t *testing.T) { - s := deal(t, duel(), 100, 5) + s := deal(t, duel(), 5) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}} @@ -208,7 +208,7 @@ func TestACallIsSpentWhenTheHandGrows(t *testing.T) { // Draw, and the hand is two: the word you said was about a card you no longer // hold on its own. - next, _, err := ApplyMove(s, Move{Kind: MoveDraw}) + next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } @@ -220,17 +220,17 @@ func TestACallIsSpentWhenTheHandGrows(t *testing.T) { // TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else. func TestCatchable(t *testing.T) { s := quietBot(t, false) - if got := s.Catchable(); len(got) != 1 || got[0] != 1 { + if got := s.Catchable(You); len(got) != 1 || got[0] != 1 { t.Errorf("Catchable() = %v, want [1]", got) } clean := quietBot(t, true) - if got := clean.Catchable(); len(got) != 0 { + if got := clean.Catchable(You); len(got) != 0 { t.Errorf("Catchable() = %v on a bot that called, want none", got) } // And not on somebody else's turn: you can only call it out when it's on you. off := quietBot(t, false) off.Turn = 1 - if got := off.Catchable(); len(got) != 0 { + if got := off.Catchable(You); len(got) != 0 { t.Errorf("Catchable() = %v off-turn, want none", got) } } @@ -240,7 +240,7 @@ func TestCatchable(t *testing.T) { // all" takes every card of its colour with it, so a six-card hand can land on // one, and a browser subtracting one from six gets a player caught. func TestUnoAtSeesThroughDiscardAll(t *testing.T) { - s := deal(t, nmDuel(), 100, 3) + s := deal(t, nmDuel(), 3) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}} @@ -249,7 +249,7 @@ func TestUnoAtSeesThroughDiscardAll(t *testing.T) { // Index 0 dumps itself and the three other reds: five cards become one. // Index 4 is an ordinary play: five become four. - got := s.UnoAt() + got := s.UnoAt(You) if len(got) != 1 || got[0] != 0 { t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got) } @@ -258,7 +258,7 @@ func TestUnoAtSeesThroughDiscardAll(t *testing.T) { // TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call. func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) { s := oneCardAway(t, 1) - got := s.UnoAt() + got := s.UnoAt(You) if len(got) != 2 { t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got) } @@ -267,19 +267,19 @@ func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) { // TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody // owes the table a word for winning. func TestGoingOutNeedsNoCall(t *testing.T) { - s := deal(t, duel(), 100, 9) + s := deal(t, duel(), 9) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}} s.Turn = You s.Phase = PhasePlay - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play the last card: %v", err) } - if !next.Outcome.Won() { - t.Fatalf("outcome %q, want a win", next.Outcome) + if next.Winner != You { + t.Fatalf("winner %d outcome %q, want a win for you", next.Winner, next.Outcome) } if hasKind(evs, EvCaught) { t.Error("caught for not calling UNO on the card that won the game") @@ -292,7 +292,7 @@ func TestGoingOutNeedsNoCall(t *testing.T) { func TestABotThatForgetsSaysNothing(t *testing.T) { quiet := 0 for seed := uint64(0); seed < 300 && quiet < 1; seed++ { - s := deal(t, duel(), 100, seed) + s := deal(t, duel(), seed) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}} @@ -302,7 +302,7 @@ func TestABotThatForgetsSaysNothing(t *testing.T) { s.Turn = You // the bot plays on the back of your move // Draw, handing the turn over: the bot plays a red and lands on one card. - next, evs, err := ApplyMove(s, Move{Kind: MoveDraw}) + next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } @@ -313,7 +313,7 @@ func TestABotThatForgetsSaysNothing(t *testing.T) { if hasKind(evs, EvUno) { t.Error("a bot that forgot to call still announced it") } - if len(next.Catchable()) != 1 { + if len(next.Catchable(You)) != 1 { t.Error("a quiet bot on one card isn't catchable") } } diff --git a/internal/games/uno/nomercy.go b/internal/games/uno/nomercy.go index 6b66a00..6e61f73 100644 --- a/internal/games/uno/nomercy.go +++ b/internal/games/uno/nomercy.go @@ -109,10 +109,10 @@ func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) { s.Pending = 0 s.deal(seat, n, true, evs, rng) - // The seat can die paying the bill, and a mercy kill can end the whole game — - // the player dying, or the last bot dying and leaving you alone at the table. - // So the phase is only reset if there is still a game to have a phase. - if s.mercy(seat, evs, rng) && s.Phase == PhaseDone { + // The seat can die paying the bill, and a mercy kill can end the hand — the last + // seat but one dying leaves somebody alone to take the pot. So the phase is only + // reset if there is still a hand to have a phase. + if s.mercy(seat, evs, rng) && !s.playing() { return } if !s.live(seat) { @@ -188,13 +188,13 @@ func (s *State) discardAll(seat int, color Color, evs *[]Event) int { } // mercy checks a seat against the limit and, if it has crossed it, takes it out -// of the game: its cards go back into the deck and it never plays again. It +// of the hand: its cards go back into the deck and it plays no more this hand. It // reports whether the seat died. // -// What that *means* depends on who it was. You dying is the game over — the -// stake is gone whatever the bots do next. A bot dying leaves a table with one -// fewer seat, and if it leaves you alone at it, you have won: everybody who could -// have beaten you to the last card is dead. +// A mercy kill is no longer game over for anyone — it is one seat out of the +// current hand, human or bot alike, and the hand plays on among the living. When +// it leaves exactly one seat alive, that seat has outlived the table and takes the +// pot; whoever it is, they win it the same way going out first would. func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool { if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit { return false @@ -207,12 +207,8 @@ func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool { s.Pending = 0 // a dead seat pays no bill, and leaves none behind *evs = append(*evs, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0})) - if seat == You { - s.lose(evs) - return true - } if alive := s.alive(); len(alive) == 1 { - s.settle(alive[0], evs) // you outlived the table + s.settle(alive[0], OutcomeWon, evs) // the last one standing takes the pot } return true } @@ -233,17 +229,9 @@ func (s State) alive() []int { return out } -// live reports whether a seat is still playing. Out is empty in a normal game and -// in any game saved before No Mercy existed, so a missing entry is a living seat. +// live reports whether a seat is still in the current hand. Out is empty between +// hands and in any game saved before No Mercy existed, so a missing entry is a +// living seat. func (s State) live(seat int) bool { return seat >= len(s.Out) || !s.Out[seat] } - -// lose ends the game against the player without anybody having gone out — which -// is what a mercy kill on seat zero is. -func (s *State) lose(evs *[]Event) { - s.Phase = PhaseDone - s.Outcome = OutcomeLost - s.Payout = 0 - *evs = append(*evs, Event{Kind: EvSettle, Seat: You, Text: string(OutcomeLost)}) -} diff --git a/internal/games/uno/nomercy_test.go b/internal/games/uno/nomercy_test.go index a7b4b03..3a0134e 100644 --- a/internal/games/uno/nomercy_test.go +++ b/internal/games/uno/nomercy_test.go @@ -33,32 +33,23 @@ func TestNoMercyDeckIsADeck(t *testing.T) { t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n) } } - // The normal deck's wilds are not in this one, and its coloured +4 is not in - // the normal one. They are different cards that print the same thing. if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 { t.Error("the No Mercy deck should print none of the normal wilds") } } -// TestNoMercyCensus is the load-bearing one, and the same one the normal game -// has: 168 cards, each in exactly one place, checked after every move of a -// hundred games played to the end. -// -// It is what would catch the two new ways this deck can lose a card. Discard All -// buries a whole colour under the pile, and a mercy kill shovels a -// twenty-five-card hand back into the deck — either of those dropping a card on -// the floor is a deck that quietly shrinks until the table can't be dealt. +// TestNoMercyCensus is the load-bearing one: 168 cards, each in exactly one place, +// checked after every move of a hundred hands played to the end. func TestNoMercyCensus(t *testing.T) { for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} { for seed := uint64(0); seed < 100; seed++ { - s := deal(t, tier, 100, seed) - start := census(s) - if got := total(start); got != 168 { + s := deal(t, tier, seed) + if got := total(census(s)); got != 168 { t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got) } rng := rand.New(rand.NewPCG(seed, 99)) - for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ { - next, _, err := ApplyMove(s, naive(s, rng)) + for moves := 0; s.playing() && moves < 800; moves++ { + next, _, err := ApplyMove(s, You, naive(s, rng)) if err != nil { t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase) } @@ -68,38 +59,31 @@ func TestNoMercyCensus(t *testing.T) { tier.Slug, seed, total(got)) } } - if s.Phase != PhaseDone { - t.Fatalf("%s seed %d: game never ended", tier.Slug, seed) + if s.playing() { + t.Fatalf("%s seed %d: hand never ended", tier.Slug, seed) } } } } -// naive is the strategy the multiples are priced against: play the first legal -// card you hold, take a stack you can't answer, and draw when you have nothing. -// It is a real way to play and a bad one, which is exactly what a house edge is -// measured against. +// naive is a bad-but-real strategy: play the first legal card, take a stack you +// can't answer, and draw when you have nothing. Always played from the human seat. func naive(s State, rng *rand.Rand) Move { if s.Phase == PhaseStack { - if p := s.Playable(); len(p) > 0 { + if p := s.Playable(You); len(p) > 0 { return playMove(s, p[0], rng) } return Move{Kind: MoveTake} } - if p := s.Playable(); len(p) > 0 { + if p := s.Playable(You); len(p) > 0 { return playMove(s, p[0], rng) } return Move{Kind: MoveDraw} } // stack loads a seat's hand up to n cards by taking them off the deck, so the -// table still holds 168 of them. Every card it moves is one that can't be played -// on the pile, which is what a hand on its way to the mercy limit looks like. +// table still holds 168 of them. func stack(s *State, seat, n int) { - // Every card the seat was holding goes back in the deck first, so the table is - // whole before we take n out of it again. The pile keeps whatever the deal - // turned over — replacing it with a card of our choosing would quietly destroy - // one, and the census below would blame the engine for it. s.Deck = append(s.Deck, s.Hands[seat]...) s.Hands[seat] = nil s.Color = s.top().Color @@ -120,15 +104,7 @@ func playMove(s State, idx int, rng *rand.Rand) Move { if s.Hands[You][idx].IsWild() { m.Color = Red + Color(rng.IntN(4)) } - // It calls UNO. That is not a strategy, it is a button — the table puts it in - // front of you and waits — and what these multiples price is *bad card play*, - // not a player who ignores the felt shouting at them. - // - // A player who genuinely never calls loses far more than this one does, and - // nothing here is priced for them. That is the rule having teeth, which is the - // point of it. Naive is the floor for someone playing the game badly, not for - // someone not playing it. - for _, at := range s.UnoAt() { + for _, at := range s.UnoAt(You) { if at == idx { m.Uno = true } @@ -136,13 +112,10 @@ func playMove(s State, idx int, rng *rand.Rand) Move { return m } -// TestAStackIsPassedOnAndPaidOnce walks the one rule the whole mode turns on: a -// draw card doesn't land on you, it *opens a bill*, and the seat that can't -// answer pays the whole thing. +// TestAStackIsPassedOnAndPaid walks the rule the whole mode turns on: a draw card +// opens a bill, and the seat that can't answer pays the whole thing. func TestAStackIsPassedOnAndPaid(t *testing.T) { - s := deal(t, nmDuel(), 100, 7) - // Rig it: you hold a +2 on a red pile, the bot holds one card that can answer - // and one that can't. + s := deal(t, nmDuel(), 7) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}} @@ -150,9 +123,7 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) { s.Turn = You s.Phase = PhasePlay - // You play the +2. The bot answers with its own, so the bill comes back to you - // at four — and you have nothing to answer with, so you pay it. - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play +2: %v", err) } @@ -168,22 +139,15 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) { if !hasKind(evs, EvStack) { t.Error("no stack event: the felt has nothing to show the player") } - // You cannot draw your way out of it, and you cannot play a card that isn't a - // draw card. - if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustStack { + if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustStack { t.Errorf("drawing out of a stack: %v, want ErrMustStack", err) } - if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack { + if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack { t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err) } - // Pay it. The bot is left holding one card it cannot play, and — because No - // Mercy makes it draw until it can — it will draw into a fresh hand and may - // well open a *new* stack on the way. That's the game working, not a leak, so - // what's asserted here is the bill this seat paid, not the state of the table - // afterwards: four cards into the hand, and the bill discharged. before := len(next.Hands[You]) - paid, evs, err := ApplyMove(next, Move{Kind: MoveTake}) + paid, evs, err := ApplyMove(next, You, Move{Kind: MoveTake}) if err != nil { t.Fatalf("take: %v", err) } @@ -199,54 +163,49 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) { if len(paid.Hands[You]) < before+4 { t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You])) } - // The bill you paid is gone. Anything pending now is a new stack the bot - // opened after yours was settled, and it is never the one you just paid. if paid.Pending == 4 && paid.Phase == PhaseStack { t.Error("the bill you just paid is still standing") } } -// TestTwentyFiveCardsKillsYou is the mercy rule, from the player's side: the -// stake is gone the moment the hand hits the limit, whoever else is still playing. -func TestTwentyFiveCardsKillsYou(t *testing.T) { - s := deal(t, nmFull(), 100, 3) - // Twenty-four cards in your hand, and a stack of ten pointed at you. - // - // The cards are *moved* from the deck, not invented: a fixture that conjures - // a hand out of nothing breaks the census before the engine gets a chance to, - // and then the census assertion below is testing the fixture instead of the - // mercy rule. +// TestMercyKillsThePlayerButNotTheSession: the mercy limit takes the human out of +// the *hand* — the pot goes to whoever is left, and the table plays on. It is no +// longer game over. +func TestMercyKillsThePlayerButNotTheSession(t *testing.T) { + s := deal(t, nmDuel(), 3) stack(&s, You, 24) s.Turn = You s.Phase = PhaseStack s.Pending = 10 - next, evs, err := ApplyMove(s, Move{Kind: MoveTake}) + next, evs, err := ApplyMove(s, You, Move{Kind: MoveTake}) if err != nil { t.Fatalf("take: %v", err) } if !hasKind(evs, EvMercy) { t.Fatal("no mercy event: twenty-five cards should have killed the seat") } - if next.Phase != PhaseDone || next.Outcome != OutcomeLost { - t.Fatalf("phase %s outcome %q, want done/lost", next.Phase, next.Outcome) + if next.playing() { + t.Fatalf("the hand should be over once one seat is left: phase %s", next.Phase) } - if next.Payout != 0 { - t.Errorf("a mercy kill paid out %d, want nothing", next.Payout) + if next.live(You) || len(next.Hands[You]) != 0 { + t.Error("a dead seat should hold no cards and be out of the hand") } - if len(next.Hands[You]) != 0 || next.live(You) { - t.Error("a dead seat should hold no cards and be out of the game") + // Heads-up, killing the human leaves the bot alone: it takes the pot. + if next.Winner != 1 { + t.Errorf("winner is seat %d, want the surviving bot", next.Winner) } if got := total(census(next)); got != 168 { t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got) } } -// TestOutlivingTheTableWins is the other side of the mercy rule, and the one -// that makes No Mercy pay less than it looks like it should: the deck buries bots -// too, and a table with every bot dead is a table you have won. +// TestOutlivingTheTableWins: the deck buries bots too, and a table with every bot +// dead is a pot you have taken. func TestOutlivingTheTableWins(t *testing.T) { - s := deal(t, nmDuel(), 100, 11) + s := deal(t, nmDuel(), 11) + pot := s.Pot + before := s.Seats[You].Stack s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}} @@ -257,63 +216,56 @@ func TestOutlivingTheTableWins(t *testing.T) { s.Turn = You s.Phase = PhasePlay - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play +2: %v", err) } if !hasKind(evs, EvMercy) { t.Fatal("the bot should have died taking the stack") } - if next.Phase != PhaseDone || next.Outcome != OutcomeWon { - t.Fatalf("phase %s outcome %q, want done/won: the last seat standing wins", - next.Phase, next.Outcome) + if next.playing() || next.Winner != You { + t.Fatalf("phase %s winner %d, want a finished hand won by you", next.Phase, next.Winner) } - if next.Payout != next.Pays() { - t.Errorf("paid %d, quoted %d — settle and the felt must agree", next.Payout, next.Pays()) + profit := pot - s.Tier.Ante + wantWon := pot - int64(float64(profit)*rake) + if next.Seats[You].Stack != before+wantWon { + t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon) } } -// TestYouDrawUntilYouCanPlay: no drawing one card and shrugging. The turn only -// moves on when the deck itself has nothing left. func TestYouDrawUntilYouCanPlay(t *testing.T) { - s := deal(t, nmDuel(), 100, 5) + s := deal(t, nmDuel(), 5) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Blue, One}} // nothing playable - // A deck whose first two cards are dead and whose third plays. s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}} s.Turn = You s.Phase = PhasePlay - next, _, err := ApplyMove(s, Move{Kind: MoveDraw}) + next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } if len(next.Hands[You]) != 4 { - t.Fatalf("hand is %d, want 4: you draw until something plays", - len(next.Hands[You])) + t.Fatalf("hand is %d, want 4: you draw until something plays", len(next.Hands[You])) } if next.Phase != PhaseDrawn { - t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play", - next.Phase) + t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play", next.Phase) } - // And you may not pass on it: you drew for it, you play it. - if _, _, err := ApplyMove(next, Move{Kind: MovePass}); err != ErrMustPlayNow { + if _, _, err := ApplyMove(next, You, Move{Kind: MovePass}); err != ErrMustPlayNow { t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err) } } -// TestSkipAllComesBackToYou — everyone else loses their turn, so the turn never -// actually leaves the seat that played it. func TestSkipAllComesBackToYou(t *testing.T) { - s := deal(t, nmFull(), 100, 13) + s := deal(t, nmFull(), 13) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}} s.Turn = You s.Phase = PhasePlay - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play skip-all: %v", err) } @@ -325,10 +277,8 @@ func TestSkipAllComesBackToYou(t *testing.T) { } } -// TestDiscardAllTakesTheColourWithIt, and the cards it takes are still in the -// game — buried under the pile, not deleted. func TestDiscardAllTakesTheColourWithIt(t *testing.T) { - s := deal(t, nmDuel(), 100, 17) + s := deal(t, nmDuel(), 17) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}} @@ -336,22 +286,26 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) { s.Phase = PhasePlay before := total(census(s)) - // With the call, because this play takes three reds out of the hand at once and - // lands on exactly one card — which is precisely the case a browser counting - // "hand length minus one" would miss, and the reason UnoAt is the engine's job. - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true}) if err != nil { t.Fatalf("play discard-all: %v", err) } if len(next.Hands[You]) != 1 { - t.Fatalf("hand is %d, want 1: every red should have gone with it", - len(next.Hands[You])) + t.Fatalf("hand is %d, want 1: every red should have gone with it", len(next.Hands[You])) } if next.Hands[You][0] != (Card{Blue, Two}) { t.Errorf("kept %v, want the blue two", next.Hands[You][0]) } - if top := next.Top(); top.Value != DiscardAll { - t.Errorf("the card in play is %v, want the discard-all that was played", top.Value) + // The discard-all was played, so it is on the pile — the bot may have played over + // it since, which is why this looks for it rather than at the very top. + found := false + for _, c := range next.Discard { + if c.Value == DiscardAll { + found = true + } + } + if !found { + t.Error("the discard-all isn't on the pile") } if !hasKind(evs, EvDiscardAll) { t.Error("no discard event") @@ -361,9 +315,8 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) { } } -// TestRouletteFlipsUntilTheColour — and the victim keeps every card it turned. func TestRouletteFlipsUntilTheColour(t *testing.T) { - s := deal(t, nmDuel(), 100, 19) + s := deal(t, nmDuel(), 19) s.Color = Blue s.Discard = []Card{{Blue, Five}} s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}} @@ -372,8 +325,7 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) { s.Turn = You s.Phase = PhasePlay - // Name red: the bot flips blue, green, yellow, red — four cards — and keeps them. - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Red}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Red}) if err != nil { t.Fatalf("play roulette: %v", err) } @@ -386,8 +338,6 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) { if got != 4 { t.Errorf("flipped %d, want 4 — up to and including the first red", got) } - // One card it started with, plus the four it turned. (The bot is then skipped, - // so the turn is back with you and it never played any of them.) if n := len(next.Hands[1]); n != 5 { t.Errorf("the bot holds %d, want 5", n) } @@ -395,43 +345,3 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) { t.Error("the roulette lost a card") } } - -// TestTheMultiplesAreStillPriced measures the naive strategy against the bots and -// checks each tier still charges roughly the house's edge for it. -// -// This is the test that fails when somebody changes the bots, the deck, or a -// rule, and it is *supposed* to: the tier and the game it prices are a pair. If -// this goes red, re-measure and move the number, don't loosen the bound. -func TestTheMultiplesAreStillPriced(t *testing.T) { - if testing.Short() { - t.Skip("slow: plays thousands of games") - } - for _, tier := range AllTiers() { - wins, games := 0, 3000 - for seed := 0; seed < games; seed++ { - s := deal(t, tier, 100, uint64(seed)+7777) - rng := rand.New(rand.NewPCG(uint64(seed), 4242)) - for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ { - next, _, err := ApplyMove(s, naive(s, rng)) - if err != nil { - t.Fatalf("%s: %v", tier.Slug, err) - } - s = next - } - if s.Outcome.Won() { - wins++ - } - } - p := float64(wins) / float64(games) - // What a staked chip comes back as, playing badly: you win p of the time and - // keep the multiple less the rake on the profit, and lose the stake the rest. - ev := p*(1+(tier.Base-1)*(1-rake)) - 1 - t.Logf("%-8s bots=%d base=%.2f naive win rate %.1f%% house edge %.1f%%", - tier.Slug, tier.Bots, tier.Base, p*100, -ev*100) - if ev < -0.14 || ev > -0.02 { - t.Errorf("%s: the house edge on naive play is %.1f%%, which is outside the 2–14%% "+ - "band the tiers are priced to. Re-measure Base: %.2f would put it near 8%%.", - tier.Slug, -ev*100, (0.92/p-1)/(1-rake)+1) - } - } -} diff --git a/internal/games/uno/uno.go b/internal/games/uno/uno.go index 8a45cd7..127cd58 100644 --- a/internal/games/uno/uno.go +++ b/internal/games/uno/uno.go @@ -1,17 +1,24 @@ -// Package uno is a pure UNO engine, played for chips against bots. +// Package uno is a pure UNO engine, played for chips at a shared table. // -// Same seam as the other four tables: ApplyMove(state, move) (state, events, +// Same seam as the other tables: ApplyMove(state, seat, move) (state, events, // error), where an error means the move was illegal and nothing else. No HTTP, // no timers, no sockets, no player names off the wire. The state is a plain // value, so a game survives a redeploy and replays from its seed. // -// Two things make UNO different from the tables already on the felt. +// UNO is a session, not a game — the shape hold'em already ships. You sit down +// with a stack of chips, and every hand each seat antes into a pot that the +// winner takes, less the house's rake. Chips cross the border exactly twice, at +// sit-down and get-up; in between, a hand settles by moving the pot between seat +// stacks inside this blob and credits nobody. A table is a list of seats, and +// who is human is a property of each seat, not of its index — solo play is just +// a table nobody else has joined. // -// The bots move inside ApplyMove. A turn-based game against opponents is -// normally where you reach for a socket, and the plan says solo UNO must not: -// so one call from the browser plays the player's move *and* every bot turn that -// follows it, and hands back the whole run as events. The table animates them in -// order. The browser is never waiting on the server to think of something. +// Two things make UNO different from the other felts. +// +// The bots move inside ApplyMove. A turn-based game against opponents is normally +// where you reach for a socket: one call from a human plays their move *and* +// every bot turn that follows it, up to the next human's decision, and hands back +// the whole run as events. The table animates them in order. // // The RNG is in the state, not an argument. The bots make choices and a spent // deck gets reshuffled, so the engine needs randomness mid-game — but a reducer @@ -41,16 +48,21 @@ var ( ErrNoStack = errors.New("uno: there's no stack to take") ErrNoCatch = errors.New("uno: there's nobody to catch there") ErrUnknownMove = errors.New("uno: unknown move") - ErrBadBet = errors.New("uno: bet must be positive") ErrUnknownTier = errors.New("uno: no such tier") + ErrHandLive = errors.New("uno: a hand is in progress") + ErrNoHand = errors.New("uno: there's no hand in progress") + ErrTableFull = errors.New("uno: the table is full") + ErrSeatTaken = errors.New("uno: that seat is taken") + ErrBadBuyIn = errors.New("uno: that isn't a legal buy-in for this table") ) -// You are always seat zero. The bots are the seats after you. -const You = 0 - // HandSize is the deal. Seven each, as printed on the box. const HandSize = 7 +// MaxSeats is the biggest a table gets. The bot pool is larger, so a full table +// never has two of the same name. +const MaxSeats = 8 + // Color is a card's colour. Wild has none until it's played. // // Wild is deliberately the zero value. A wild played with no colour named is the @@ -179,7 +191,7 @@ func (c Card) CanPlayOn(top Card, topColor Color) bool { } // NewDeck builds the 108: one zero and two each of 1-9, skip, reverse and +2 in -// every colour, plus four wilds and four wild draw fours. Unshuffled — Deal +// every colour, plus four wilds and four wild draw fours. Unshuffled — the deal // shuffles, and a test wants the fixed order. func NewDeck() []Card { d := make([]Card, 0, 108) @@ -195,20 +207,21 @@ func NewDeck() []Card { return d } -// Tier is a table, and the table size *is* the difficulty. More bots is a longer -// shot — three of them going out before you is three ways to lose — so it pays -// more. This is the tier dial every other game here has, pointed at the one knob -// UNO actually has. -// No Mercy rides on the same struct rather than a second one, because it is the -// tier that lands in the state and the payload — so a game carries which rules it -// is playing by, and cannot be reloaded into the other set. +// Tier is a table. The table size is the difficulty — more seats is a longer +// shot at being first out — and the ante is the stake. No Mercy rides on the same +// struct rather than a second one, because it is the tier that lands in the state +// and the payload, so a game carries which rules it is playing by and cannot be +// reloaded into the other set. type Tier struct { Slug string `json:"slug"` Name string `json:"name"` Bots int `json:"bots"` - Base float64 `json:"base"` // what going out first pays, before the rake + Ante int64 `json:"ante"` // what every seat puts in the pot each hand + MinBuy int64 `json:"min_buy"` // the smallest stack you may sit down with + MaxBuy int64 `json:"max_buy"` // and the largest Blurb string `json:"blurb"` NoMercy bool `json:"no_mercy"` + RakePct float64 `json:"rake_pct"` // set by New, so a state knows its own rake } // Deck is the deck this tier plays with. @@ -219,65 +232,34 @@ func (t Tier) Deck() []Card { return NewDeck() } -// Tiers are the three tables. -// -// The multiples are not guesses. A player who simply plays the first legal card -// they hold — which is a real strategy, and a bad one — goes out first 43% of -// the time heads up, 32% at three seats and 27% at four. These pay a little -// under what that costs, so bad play loses slowly and good play (holding the -// wilds, dumping the colour you're long in, counting what a bot picked up) is -// worth roughly the house's edge. That is the game being about something. -// Re-measured 2026-07-14, and they moved: the naive strategy now wins 40.3% / -// 29.2% / 23.3%, not the 43 / 32 / 27 these were originally priced off. The bots -// got better at some point after the multiples were set and nobody re-ran the -// measurement, so Table and Full House had quietly been charging an 18–19% edge -// instead of the 8% they were meant to. The numbers below are the honest ones. -// -// This is exactly the drift TestTheMultiplesAreStillPriced now exists to stop. -// -// Re-measured again when the UNO call went in (call.go): 40.1% / 28.5% / 23.1%, -// which is the same game. That is the point — the naive player *calls*, because -// calling is a button and not a strategy, so the rule costs them nothing and -// these multiples stand. What the rule adds is upside for a player who watches -// the counts and catches a quiet bot, which is the good play these tiers are -// meant to leave room for. +// The stakes every UNO table plays for. There is no measured multiple any more — +// the pot does the paying, the winner takes it less rake, and the house edge is +// exactly the rake. All the tables share an ante and buy-in range; the tier is +// only the table size. +const ( + tierAnte = 50 + tierMinBuy = 500 // ten antes: enough to sit and play a while + tierMaxBuy = 5000 // and the ceiling, so nobody buys the table +) + +// Tiers are the three normal-deck tables. var Tiers = []Tier{ - {Slug: "duel", Name: "Duel", Bots: 1, Base: 2.4, + {Slug: "duel", Name: "Duel", Bots: 1, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, Blurb: "One bot, head to head. A reverse is a skip with two at the table."}, - {Slug: "table", Name: "Table", Bots: 2, Base: 3.3, + {Slug: "table", Name: "Table", Bots: 2, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, Blurb: "Two bots. Twice the +4s pointed at you."}, - {Slug: "full", Name: "Full House", Bots: 3, Base: 4.1, - Blurb: "Three bots, and any of them going out first takes your stake."}, + {Slug: "full", Name: "Full House", Bots: 3, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, + Blurb: "Three bots, and any of them going out first takes the pot."}, } // NoMercyTiers are the same three tables playing the other rules. -// -// The multiples are measured, not guessed, and they are *not* the normal ones — -// the naive strategy (play the first legal card; take a stack you can't answer) -// wins 45.6% / 31.8% / 27.4% here, against 40.1 / 28.5 / 23.1 on the normal deck. -// -// Which is to say: **No Mercy is easier than UNO**, at every table size, and so -// it pays less. That reads backwards until you see why. The mercy rule kills -// *bots* — it does not care whose hand hits twenty-five — and every bot it buries -// is one fewer seat that can beat you to the last card. Three opponents burying -// each other is a game you win by outliving, and the deck that was built to be -// merciless turns out to be merciless mostly to the table. -// -// So a nastier game pays a smaller multiple, which is the correct answer and a -// slightly funny one. TestTheMultiplesAreStillPriced is what keeps it honest: -// change the bots, the deck or a rule, and it fails until these are measured -// again. It is the test the normal tiers never had, which is how they drifted. var NoMercyTiers = []Tier{ - {Slug: "nm-duel", Name: "No Mercy Duel", Bots: 1, Base: 2.0, NoMercy: true, + {Slug: "nm-duel", Name: "No Mercy Duel", Bots: 1, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, NoMercy: true, Blurb: "One bot, 168 cards. Stack the draws or eat them."}, - {Slug: "nm-table", Name: "No Mercy Table", Bots: 2, Base: 3.1, NoMercy: true, + {Slug: "nm-table", Name: "No Mercy Table", Bots: 2, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, NoMercy: true, Blurb: "Two bots. A +10 answered twice is somebody's whole hand."}, - // Re-priced 2026-07-14 with the call rule in: 3.8 was paying a *negative* house - // edge here (-0.2%), which is the house paying you to sit down. The naive win - // rate at this table is 27.4%, not the 25.3% it was priced off — three bots - // burying each other is even better for you than the last measurement caught. - {Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Base: 3.5, NoMercy: true, - Blurb: "Three bots. Twenty-five cards and you're out of the game."}, + {Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, NoMercy: true, + Blurb: "Three bots. Twenty-five cards and you're out of the hand."}, } // AllTiers is every table in the room, both dials. @@ -299,41 +281,60 @@ func TierBySlug(slug string) (Tier, error) { type Phase string const ( - PhasePlay Phase = "play" // your turn, play or draw - PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass - PhaseStack Phase = "stack" // No Mercy: a draw card is pointed at you — answer it or take it - PhaseDone Phase = "done" + PhaseHandOver Phase = "handover" // between hands; a Deal starts the next + PhasePlay Phase = "play" // your turn, play or draw + PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass + PhaseStack Phase = "stack" // No Mercy: a draw card is pointed at you — answer it or take it + PhaseDone Phase = "done" // a solo session has ended and the seat is cashing out ) -// Outcome is how it ended. +// Outcome is how a hand ended. It describes the hand, not a seat — the winner is +// carried separately. type Outcome string const ( OutcomeNone Outcome = "" - OutcomeWon Outcome = "won" // you went out first - OutcomeLost Outcome = "lost" // a bot did - OutcomeStuck Outcome = "stuck" // nobody can move and there are no cards left + OutcomeWon Outcome = "won" // a seat went out + OutcomeStuck Outcome = "stuck" // nobody could move, and the shortest hand took it + OutcomeTie Outcome = "tie" // stuck and level: the antes went back ) -// Won reports whether this outcome pays. -func (o Outcome) Won() bool { return o == OutcomeWon } +// Seat is one chair at the table. A shared table is seated by the runtime: humans +// in the chairs people took, bots in the rest. +type Seat struct { + Name string `json:"name"` + Bot bool `json:"bot"` + Stack int64 `json:"stack"` // chips in front; a human's real, a bot's house money + Waiting bool `json:"waiting,omitempty"` // joined mid-session; dealt in at the next hand + Ante int64 `json:"ante,omitempty"` // what this seat put in the pot this hand + Won int64 `json:"won,omitempty"` // what this seat took from the pot this hand, net of rake +} -// State is one game. The bots' hands and the deck are in here, which is exactly -// why this value never crosses the wire — the browser gets counts instead. +// SeatConfig is one chair the table opens with — the shape New is seated from. +type SeatConfig struct { + Name string + Bot bool + Stack int64 +} + +// State is one table. The seats' hands and the deck are in here, which is exactly +// why this value never crosses the wire — a viewer gets their own hand and counts +// for everyone else. type State struct { Tier Tier `json:"tier"` - Hands [][]Card `json:"hands"` // seat 0 is you; the rest are bots - Bots []string `json:"bots"` // their names, one per bot seat (seat i is Bots[i-1]) + Seats []Seat `json:"seats"` + Hands [][]Card `json:"hands"` // seat i's cards Deck []Card `json:"deck"` Discard []Card `json:"discard"` // the top card is the last one Color Color `json:"color"` // the colour in play, which a wild renames Turn int `json:"turn"` - Dir int `json:"dir"` // +1 clockwise, -1 after a reverse + Dir int `json:"dir"` // +1 clockwise, -1 after a reverse + Dealer int `json:"dealer"` // rotates each hand; the seat after it acts first - // No Mercy only. Out is the seats the mercy rule has killed, and it is what - // the turn order steps over — a dead seat is skipped, not merely empty. - // Pending is the bill a stack of draw cards has run up: whoever stops - // stacking pays it. + // Out is the seats not in the current hand — mercy-killed, or sitting one out + // because they couldn't cover the ante. The turn order steps over them. + // Pending is the bill a stack of draw cards has run up: whoever stops stacking + // pays it. Out []bool `json:"out,omitempty"` Pending int `json:"pending,omitempty"` @@ -342,45 +343,58 @@ type State struct { // nothing — and it is what a catch is tested against. See call.go. Called []bool `json:"called,omitempty"` + Pot int64 `json:"pot"` // the antes riding on the current hand + Paid int64 `json:"paid"` // rake lifted from human-won pots, all session (the audit total) + BoughtIn int64 `json:"bought_in"` // the sum of what the humans brought (audit stake total) + Seed1 uint64 `json:"seed1"` Seed2 uint64 `json:"seed2"` Step uint64 `json:"step"` // how many moves have been applied; the rng's other half RakePct float64 `json:"rake_pct"` - Bet int64 `json:"bet"` Phase Phase `json:"phase"` - Outcome Outcome `json:"outcome"` - Payout int64 `json:"payout"` - Rake int64 `json:"rake"` + HandNo int `json:"hand_no"` + + // The last hand's result, for the felt to land the verdict and the audit to + // record. Winner is the seat that took the pot, or -1 for a refunded tie. + Winner int `json:"winner"` + LastPot int64 `json:"last_pot"` // gross pot the winner took + Rake int64 `json:"rake"` // rake lifted from that pot + Outcome Outcome `json:"outcome,omitempty"` + + // Payout is set only when a solo session ends (the one human gets up or busts): + // the stack that crosses the border home. A shared table never reaches PhaseDone. + Payout int64 `json:"payout,omitempty"` } -// Event is something the table animates. The bots' turns arrive as a run of -// these on the back of the player's own move, and the felt plays them in order. +// Event is something the table animates. The bots' turns arrive as a run of these +// on the back of a human's own move, and the felt plays them in order. type Event struct { Kind string `json:"kind"` // see below Seat int `json:"seat"` // who it happened to - Card *Card `json:"card,omitempty"` // the card played, or the one *you* drew + Card *Card `json:"card,omitempty"` // the card played, or one drawn Color Color `json:"color,omitempty"` // the colour now in play, on a wild N int `json:"n,omitempty"` // how many cards were drawn Left int `json:"left"` // cards left in that seat's hand afterwards By int `json:"by"` // who caught them, on a catch. Seat zero is a real answer here, so never omitempty Text string `json:"text,omitempty"` - // Hand is *your* hand as it stands after this event, and it is only ever set - // on an event that changed it. The table plays a lap of events back over - // several seconds, and for all of them the hand on screen has to be the hand - // you actually hold — a +4 that lands on you at the top of the lap must show - // up in your fan as it lands, not when the lap ends. There is no leak here: - // this is the one hand the browser is already entitled to see. + // Hand is the acting seat's hand as it stands after this event, and it is only + // ever set on an event that changed it. The engine stamps *every* seat's hand + // (it cannot know who a shared stream is for); the web layer redacts it down to + // the one hand the viewer is entitled to — the same wall hold'em's hole cards + // live behind. A missed redaction there fans a hand to every subscriber. Hand []Card `json:"hand,omitempty"` } -// mine stamps the player's hand onto an event that just changed it. Events about -// a bot's hand carry nothing — the browser never learns those, and stamping is -// scoped to seat zero precisely so it can't start. +// mine stamps the acting seat's hand onto an event that just changed it. It is +// stamped for *every* seat now, because a shared stream is watched by more than +// one human and the engine cannot know which; the redaction that keeps a hand +// private is at the web layer (viewUnoEvents), which strips every hand but the +// viewer's own. See Event.Hand. func (s *State) mine(e Event) Event { - if e.Seat == You { - e.Hand = append([]Card(nil), s.Hands[You]...) + if e.Seat >= 0 && e.Seat < len(s.Hands) { + e.Hand = append([]Card(nil), s.Hands[e.Seat]...) } return e } @@ -390,16 +404,17 @@ func (s *State) mine(e Event) Event { // deal the hands are dealt and the first card turned over // play a card goes on the pile // wild the colour was named (rides with the play it belongs to) -// draw cards come off the deck. A bot's are face down: Card is nil. +// draw cards come off the deck. A face is present; the web layer redacts it. // forced the same, but not by choice — a +2 or a +4 landed on them // pass the turn moves on with nothing played // skip a seat loses its turn // reverse the direction flips // uno a hand is down to one card, and its owner said so // caught a seat went down to one card *quietly*, and somebody noticed: +2 -// miscall you called a seat that had nothing to hide, and paid for it: +2 +// miscall a seat called one that had nothing to hide, and paid for it: +2 // reshuffle the discard goes back under -// settle it's over +// ante a seat put its ante in the pot at the deal +// settle the hand is over // // And the No Mercy ones: // @@ -407,7 +422,7 @@ func (s *State) mine(e Event) Event { // skipall everybody else loses their turn // discard a whole colour left a hand at once // roulette a seat flipped N cards looking for a colour, and kept them -// mercy a seat hit 25 cards and is out of the game +// mercy a seat hit 25 cards and is out of the hand const ( EvDeal = "deal" EvPlay = "play" @@ -420,6 +435,7 @@ const ( EvCaught = "caught" EvMiscall = "miscall" EvReshuffle = "reshuffle" + EvAnte = "ante" EvSettle = "settle" EvStack = "stack" @@ -429,10 +445,10 @@ const ( EvMercy = "mercy" ) -// Move is what the player sends: play this card, take one off the deck, or — -// having taken one you can play — decline to play it. +// Move is what a seat sends: play a card, draw off the deck, decline a card you +// drew, take a stack, catch a quiet seat, deal the next hand, or get up. type Move struct { - Kind string `json:"kind"` // "play" | "draw" | "pass" | "take" | "catch" + Kind string `json:"kind"` // see below Index int `json:"index"` // which card of your hand, for a play Color Color `json:"color"` // the colour you name, for a wild Uno bool `json:"uno"` // "…and UNO!", for a play that leaves you on one card @@ -441,87 +457,183 @@ type Move struct { // Move kinds. Take is No Mercy's: it is how you give in to a stack you can't // answer, and it is a *decision*, so it gets a name of its own rather than being -// bolted onto draw. -// -// Catch is the other half of the UNO call — see call.go. It is a move you make -// out of turn order (it costs you nothing but the risk of being wrong), so it is -// a kind rather than a flag on the moves that do cost a turn. +// bolted onto draw. Catch is the other half of the UNO call (see call.go) — a +// move you make out of turn order. Deal and Leave are the session moves: a hand +// starts and a seat gets up, both only legal between hands. const ( MovePlay = "play" MoveDraw = "draw" MovePass = "pass" MoveTake = "take" MoveCatch = "catch" + MoveDeal = "deal" + MoveLeave = "leave" ) -// New deals a game: a shuffled deck, seven each, and a card turned over. -// -// The turned card is dealt until it's a number. The official rules have the -// first player eat a +2 that lands there, and turn a wild into a colour vote — -// both of which are a game that opens by doing something to you before you have -// touched it. A number card up top is the same game, minus the paperwork. -func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Event, error) { - if bet <= 0 { - return State{}, nil, ErrBadBet +// New opens a table and seats it. No hand is dealt yet — the table opens on +// PhaseHandOver, and the first Deal starts the first hand. Solo play is just the +// case where exactly one chair is human. +func New(t Tier, seats []SeatConfig, rakePct float64, seed1, seed2 uint64) (State, []Event, error) { + if len(seats) < 2 || len(seats) > MaxSeats { + return State{}, nil, ErrTableFull } - if t.Bots < 1 { - return State{}, nil, ErrUnknownTier - } - rng := stepRNG(seed1, seed2, 0) - - deck := t.Deck() - rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] }) + t.RakePct = rakePct s := State{ - Tier: t, Deck: deck, Dir: 1, Turn: You, + Tier: t, Dir: 1, Winner: -1, Seed1: seed1, Seed2: seed2, - RakePct: rakePct, Bet: bet, Phase: PhasePlay, - Bots: botNames(t.Bots, rng), + RakePct: rakePct, Phase: PhaseHandOver, } - - seats := t.Bots + 1 - s.Out = make([]bool, seats) - s.Called = make([]bool, seats) - s.Hands = make([][]Card, seats) - for i := range s.Hands { - s.Hands[i] = make([]Card, 0, HandSize) - } - for c := 0; c < HandSize; c++ { - for seat := 0; seat < seats; seat++ { - card, _ := s.pop() - s.Hands[seat] = append(s.Hands[seat], card) + var evs []Event + for _, sc := range seats { + if !sc.Bot && (sc.Stack < t.MinBuy || sc.Stack > t.MaxBuy) { + return State{}, nil, ErrBadBuyIn + } + i := len(s.Seats) + s.Seats = append(s.Seats, Seat{Name: sc.Name, Bot: sc.Bot, Stack: sc.Stack}) + if !sc.Bot { + s.BoughtIn += sc.Stack + evs = append(evs, Event{Kind: "sit", Seat: i, N: int(sc.Stack), Text: t.Name}) } } - - // Turn cards over until one of them is a plain number. - for { - card, ok := s.pop() - if !ok { - return State{}, nil, errors.New("uno: deck ran out on the deal") // 108 cards; unreachable - } - if card.Value.Action() { - s.Discard = append(s.Discard, card) // it stays buried, out of play - continue - } - s.Discard = append(s.Discard, card) - s.Color = card.Color - break - } - - return s, []Event{s.mine(Event{Kind: EvDeal, Card: s.topPtr(), Color: s.Color})}, nil + s.Hands = make([][]Card, len(s.Seats)) + s.Out = make([]bool, len(s.Seats)) + s.Called = make([]bool, len(s.Seats)) + // The dealer starts on the last seat, so the first deal (which does not rotate + // it) leaves the seat after it — seat zero — to act first. + s.Dealer = len(s.Seats) - 1 + return s, evs, nil } -// ApplyMove is the engine. Your move goes in; your move, and every bot turn it +// SoloSeats builds the seat list for a table of one human and n bots — the shape +// the solo handler opens. The human is seat zero and takes buyIn. +func SoloSeats(t Tier, bots int, buyIn int64) []SeatConfig { + return TableSeats(t, "You", bots, buyIn) +} + +// TableSeats builds a table of one named human and n bots. The human takes seat +// zero and their buy-in; each bot takes house chips (not real money) enough to +// ante with, rebought as needed. See New. +func TableSeats(t Tier, human string, bots int, buyIn int64) []SeatConfig { + seats := []SeatConfig{{Name: human, Stack: buyIn}} + for i := 0; i < bots && i < len(botPool); i++ { + seats = append(seats, SeatConfig{Name: botPool[i], Bot: true, Stack: t.MaxBuy}) + } + return seats +} + +// freeBotName picks a regular not already sitting at the table, so vacating a +// seat never puts two of the same name on the felt. +func (s *State) freeBotName() string { + used := make(map[string]bool, len(s.Seats)) + for i := range s.Seats { + used[s.Seats[i].Name] = true + } + for _, n := range botPool { + if !used[n] { + return n + } + } + return "The House" +} + +// Vacate turns a human's chair back into the house's and returns the stack that +// goes home with them. The seat keeps its place and its chips — which become house +// money, rebought like any bot's — so the others play on without a hole in the +// ring. It refuses mid-hand, because a seat with an ante in the pot cannot be +// emptied without stranding it. +func (s *State) Vacate(seat int) (int64, error) { + if seat < 0 || seat >= len(s.Seats) { + return 0, ErrUnknownMove + } + if s.Phase != PhaseHandOver && s.Phase != PhaseDone { + return 0, ErrHandLive + } + p := &s.Seats[seat] + if p.Bot { + return 0, ErrUnknownMove + } + home := p.Stack + p.Bot = true + p.Name = s.freeBotName() + p.Waiting = false + return home, nil +} + +// Occupy seats a human in a chair a bot was keeping warm, with the buy-in they +// brought. Like Vacate it is a between-hands move — you cannot sit into a live +// hand — and the seat waits out the current gap until the next deal brings it in. +func (s *State) Occupy(seat int, name string, buyIn int64) error { + if seat < 0 || seat >= len(s.Seats) { + return ErrUnknownMove + } + if s.Phase != PhaseHandOver { + return ErrHandLive + } + if !s.Seats[seat].Bot { + return ErrSeatTaken + } + if buyIn < s.Tier.MinBuy || buyIn > s.Tier.MaxBuy { + return ErrBadBuyIn + } + p := &s.Seats[seat] + p.Bot = false + p.Name = name + p.Stack = buyIn + p.Waiting = true // dealt in at the next hand + s.BoughtIn += buyIn + return nil +} + +// ApplyMove is the engine. A seat's move goes in; that move, and every bot turn it // hands off to, comes back out. An error means the move was illegal and the // caller's state is untouched. -func ApplyMove(s State, m Move) (State, []Event, error) { +// +// seat is who is acting. A hand move is legal only from the seat whose turn it is +// (a catch is the exception — it is out of turn by design); the session moves +// (Deal, Leave) belong to the seat that sent them. This is the one place seat +// identity enters the engine. +func ApplyMove(s State, seat int, m Move) (State, []Event, error) { + if seat < 0 || seat >= len(s.Seats) { + return s, nil, ErrUnknownMove + } if s.Phase == PhaseDone { return s, nil, ErrGameOver } - if s.Turn != You { - // Can't happen through this door — ApplyMove always runs the bots out - // before it returns — but a state restored from a row that predates a bug - // shouldn't wedge the player, it should say what's wrong. + + switch m.Kind { + case MoveDeal: + if s.Phase != PhaseHandOver { + return s, nil, ErrHandLive + } + next := s.clone() + next.Step++ + var evs []Event + next.dealHand(&evs) + next.resolve(&evs) + return next, evs, nil + + case MoveLeave: + // Getting up at a solo table ends the session and pays the stack out; the + // runtime reads Payout and crosses the border. At a shared table leaving is a + // storage operation and this branch is not the path taken — see the handler. + if s.Phase != PhaseHandOver { + return s, nil, ErrHandLive + } + next := s.clone() + next.Phase = PhaseDone + next.Payout = next.Seats[seat].Stack + return next, []Event{{Kind: MoveLeave, Seat: seat, N: int(next.Payout)}}, nil + } + + // A hand move. + if !s.playing() { + return s, nil, ErrNoHand + } + if s.Seats[seat].Bot { + return s, nil, ErrNotYourTurn // bots move inside the engine, never through this door + } + if m.Kind != MoveCatch && s.Turn != seat { return s, nil, ErrNotYourTurn } @@ -534,15 +646,15 @@ func ApplyMove(s State, m Move) (State, []Event, error) { var err error switch m.Kind { case MovePlay: - evs, err = next.playerPlays(m, rng) + evs, err = next.seatPlays(seat, m, rng) case MoveDraw: - evs, err = next.playerDraws(rng) + evs, err = next.seatDraws(seat, rng) case MovePass: - evs, err = next.playerPasses() + evs, err = next.seatPasses(seat) case MoveTake: - evs, err = next.playerTakes(rng) + evs, err = next.seatTakes(seat, rng) case MoveCatch: - evs, err = next.playerCatches(m, rng) + evs, err = next.seatCatches(seat, m, rng) default: return s, nil, ErrUnknownMove } @@ -550,45 +662,145 @@ func ApplyMove(s State, m Move) (State, []Event, error) { return s, nil, err // the caller's state, untouched } - // Before anybody moves: did you go down to one card without saying so? This is - // the only window there is. The bots are about to take their turns, and a bot - // that has played on is a bot that has stopped looking at your hand. - next.botsCatch(&evs, rng) - - // The bots take their turns on the back of yours, and the whole run comes - // back as one script. This is the reason solo UNO needs no socket. - // - // A catch is not a turn — it leaves the turn where it was, which is with you — - // so this is a no-op after one, and that is the whole mechanism by which - // catching a bot costs you nothing but the risk of being wrong. - next.runBots(&evs, rng) - - // And if that left a table nobody can move at, it ends here rather than - // handing back a turn that has nothing in it. See stalled(). - if next.Phase != PhaseDone && next.stalled() { - next.stuck(&evs) - } - next.tidyCalls() + // Before anybody else moves: did the acting seat go down to one card without + // saying so? This is the only window there is. The bots are about to take their + // turns, and a bot that has played on is a bot that has stopped looking. + next.botsCatch(seat, &evs, rng) + next.resolve(&evs) return next, evs, nil } -// playerPlays puts one of your cards on the pile. -func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) { - hand := s.Hands[You] +// resolve runs the bots out to the next human's decision and closes out a hand +// that ended or died under them. It is the tail every move and every deal ends on. +func (s *State) resolve(evs *[]Event) { + rng := stepRNG(s.Seed1, s.Seed2, s.Step) + s.runBots(evs, rng) + if s.playing() && s.stalled() { + s.stuck(evs) + } + s.tidyCalls() +} + +// playing reports whether a hand is in progress — as opposed to between hands or +// a solo session cashing out. +func (s State) playing() bool { + return s.Phase == PhasePlay || s.Phase == PhaseDrawn || s.Phase == PhaseStack +} + +// dealHand starts a hand: it rotates the dealer, brings in whoever is waiting, +// collects the antes, deals seven each and turns a card over. A seat that cannot +// cover the ante sits the hand out; if that leaves fewer than two able to play, a +// solo table's one human is bust and the session ends. +func (s *State) dealHand(evs *[]Event) { + if s.HandNo > 0 { + s.Dealer = (s.Dealer + 1) % len(s.Seats) + } + + // Reset per-hand state and work out who is in. + s.Out = make([]bool, len(s.Seats)) + s.Called = make([]bool, len(s.Seats)) + s.Pending = 0 + s.Pot = 0 + s.Hands = make([][]Card, len(s.Seats)) + s.Discard = nil + s.Winner, s.LastPot, s.Rake, s.Outcome = -1, 0, 0, OutcomeNone + + humans := 0 + var in []int + for i := range s.Seats { + p := &s.Seats[i] + p.Waiting = false + p.Ante, p.Won = 0, 0 + if !p.Bot { + humans++ + } + if p.Bot && p.Stack < s.Tier.Ante { + p.Stack = s.Tier.MaxBuy // the house rebuys a bot that has run low + } + if p.Stack >= s.Tier.Ante { + in = append(in, i) + } else { + s.Out[i] = true // sits this one out; can't cover the ante + } + } + + if len(in) < 2 { + // Not enough chips at the table to play a hand. At a solo table that means + // the one human is bust: end the session and pay out whatever is left. + if humans == 1 { + for i := range s.Seats { + if !s.Seats[i].Bot { + s.Phase = PhaseDone + s.Payout = s.Seats[i].Stack + *evs = append(*evs, Event{Kind: MoveLeave, Seat: i, N: int(s.Payout)}) + return + } + } + } + s.Phase = PhaseHandOver // degenerate; nothing to deal + return + } + + s.HandNo++ + s.Dir = 1 + deck := s.Tier.Deck() + rng := stepRNG(s.Seed1, s.Seed2, s.Step) + rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] }) + s.Deck = deck + + // Ante up, then deal. + for _, seat := range in { + p := &s.Seats[seat] + p.Stack -= s.Tier.Ante + p.Ante = s.Tier.Ante + s.Pot += s.Tier.Ante + *evs = append(*evs, Event{Kind: EvAnte, Seat: seat, N: int(s.Tier.Ante)}) + } + for i := range s.Hands { + s.Hands[i] = make([]Card, 0, HandSize) + } + for c := 0; c < HandSize; c++ { + for _, seat := range in { + card, _ := s.pop() + s.Hands[seat] = append(s.Hands[seat], card) + } + } + + // Turn cards over until one of them is a plain number. + for { + card, ok := s.pop() + if !ok { + break // 108 cards; unreachable + } + if card.Value.Action() { + s.Discard = append(s.Discard, card) // it stays buried, out of play + continue + } + s.Discard = append(s.Discard, card) + s.Color = card.Color + break + } + + s.Phase = PhasePlay + s.Turn = s.Dealer + s.advance(1) // the seat after the dealer acts first + *evs = append(*evs, s.mine(Event{Kind: EvDeal, Seat: s.Turn, Card: s.topPtr(), Color: s.Color})) +} + +// seatPlays puts one of a seat's cards on the pile. +func (s *State) seatPlays(seat int, m Move, rng *rand.Rand) ([]Event, error) { + hand := s.Hands[seat] if m.Index < 0 || m.Index >= len(hand) { return nil, ErrNoSuchCard } - // Having drawn a playable card, the only card you may play is that one. Being - // allowed to draw and *then* play something else would make drawing a free - // look at the deck with no cost attached. + // Having drawn a playable card, the only card you may play is that one. if s.Phase == PhaseDrawn && m.Index != len(hand)-1 { return nil, ErrMustPlayNow } card := hand[m.Index] // With a stack pointed at you, the only cards that exist are the ones that - // answer it. Everything else in your hand is unplayable until the bill is - // settled — by you, or by the seat you pass it to. + // answer it. Everything else is unplayable until the bill is settled. if s.Phase == PhaseStack { if !card.CanStackOn(s.Color) { return nil, ErrMustStack @@ -600,84 +812,69 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) { return nil, ErrNeedColor } - s.Hands[You] = append(hand[:m.Index:m.Index], hand[m.Index+1:]...) + s.Hands[seat] = append(hand[:m.Index:m.Index], hand[m.Index+1:]...) var evs []Event - s.discard(You, card, m.Color, &evs) - s.after(You, card, &evs, rng) - // Whether you called is checked *after* the card has finished resolving, not - // after it left your hand. No Mercy's "discard all" takes every card of a - // colour with it, so it can drop you from six cards to one in a single play — - // and the seat that gets there that way owes the call just the same. - s.declare(You, m.Uno, &evs) + s.discard(seat, card, m.Color, &evs) + s.after(seat, card, &evs, rng) + s.declare(seat, m.Uno, &evs) return evs, nil } -// playerDraws takes cards off the deck. -// -// The normal game takes one: if it can be played you get the choice — that's -// PhaseDrawn, the only place a turn pauses mid-move — and if it can't, the turn -// passes on the spot, because there is nothing to decide. -// -// No Mercy makes you draw *until* you can play. There is no drawing one card and -// shrugging, which is most of why hands there get big enough for the mercy rule -// to have something to kill. The card you end on is a card you must then play, so -// there is still nothing to decide — but the deck can be dry, and a hand can hit -// twenty-five on the way, and both of those end the drawing. -func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) { +// seatDraws takes cards off the deck. The normal game takes one; No Mercy makes +// you draw until you can play. See the long-form note that used to live here — the +// rules are the same, only the seat is a parameter now. +func (s *State) seatDraws(seat int, rng *rand.Rand) ([]Event, error) { if s.Phase == PhaseDrawn { - return nil, ErrMustPlayNow // you already drew; play it or pass + return nil, ErrMustPlayNow } if s.Phase == PhaseStack { - return nil, ErrMustStack // answer it or take it; you cannot draw out of a stack + return nil, ErrMustStack } var evs []Event if !s.Tier.NoMercy { - drawn := s.deal(You, 1, false, &evs, rng) + drawn := s.deal(seat, 1, false, &evs, rng) if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) { s.Phase = PhaseDrawn return evs, nil } - evs = append(evs, Event{Kind: EvPass, Seat: You}) + evs = append(evs, Event{Kind: EvPass, Seat: seat}) s.advance(1) return evs, nil } for { - drawn := s.deal(You, 1, false, &evs, rng) + drawn := s.deal(seat, 1, false, &evs, rng) if len(drawn) == 0 { break // the table has nothing left to draw } - if s.mercy(You, &evs, rng) { - return evs, nil // twenty-five cards, and you are out of the game + if s.mercy(seat, &evs, rng) { + return evs, nil // twenty-five cards, and this seat is out of the hand } if drawn[0].CanPlayOn(s.top(), s.Color) { s.Phase = PhaseDrawn return evs, nil } } - evs = append(evs, Event{Kind: EvPass, Seat: You}) + evs = append(evs, Event{Kind: EvPass, Seat: seat}) s.advance(1) return evs, nil } -// playerTakes gives in to a stack: you take every card it has run up, and you -// lose your turn. -func (s *State) playerTakes(rng *rand.Rand) ([]Event, error) { +// seatTakes gives in to a stack: the seat takes every card it has run up, and +// loses its turn. +func (s *State) seatTakes(seat int, rng *rand.Rand) ([]Event, error) { if s.Phase != PhaseStack { return nil, ErrNoStack } var evs []Event - s.absorb(You, &evs, rng) + s.absorb(seat, &evs, rng) return evs, nil } -// playerPasses declines the card you just drew. -// -// In No Mercy you may not: you drew until you found a card that plays, and that -// card is the price of having drawn. Passing there would make drawing a way to -// buy a look at the deck and put nothing down. -func (s *State) playerPasses() ([]Event, error) { +// seatPasses declines the card the seat just drew. In No Mercy you may not — the +// card you drew is the price of having drawn. +func (s *State) seatPasses(seat int) ([]Event, error) { if s.Phase != PhaseDrawn { return nil, ErrCantPass } @@ -686,15 +883,13 @@ func (s *State) playerPasses() ([]Event, error) { } s.Phase = PhasePlay s.advance(1) - return []Event{{Kind: EvPass, Seat: You}}, nil + return []Event{{Kind: EvPass, Seat: seat}}, nil } -// runBots plays every bot turn between you and your next one. It stops the -// moment the game is over, the turn comes back round, or the table dies under -// it — a stalled table would otherwise pass the turn round and round forever -// without ever reaching you. +// runBots plays every bot turn up to the next human's decision. It stops the +// moment the hand is over, the turn lands on a human, or the table dies under it. func (s *State) runBots(evs *[]Event, rng *rand.Rand) { - for s.Phase != PhaseDone && s.Turn != You && !s.stalled() { + for s.playing() && s.Seats[s.Turn].Bot && !s.stalled() { s.botTurn(s.Turn, evs, rng) } } @@ -715,9 +910,6 @@ func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) { card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng) if idx < 0 { - // Nothing playable: draw. The normal game draws one and shrugs; No Mercy - // draws until something goes, which is what buries a bot as surely as it - // buries you — the mercy rule cuts both ways, and a bot can die on the deck. for { drawn := s.deal(seat, 1, false, evs, rng) if len(drawn) != 1 { @@ -751,31 +943,16 @@ func (s *State) botPlays(seat int, card Card, idx int, evs *[]Event, rng *rand.R if card.IsWild() { color = botColor(s.Hands[seat], rng) if card.Value == WildRoulette { - // The roulette is not a card you play a colour *from*, it is a card you - // point at somebody. So the bot names the colour it holds least of, which - // is the one the deck is least likely to turn up quickly. color = botRouletteColor(s.Hands[seat], rng) } } s.discard(seat, card, color, evs) s.after(seat, card, evs, rng) - // A bot that has just gone down to one card mostly remembers to say so. When it - // doesn't, it says nothing at all — no event, no badge — and the only thing on - // the table that gives it away is the count beside its fan reading "1 card". - // Spotting that is the player's to do. s.declare(seat, !botForgets(rng), evs) } // stalled reports whether the table is dead: nothing left to draw anywhere, and // not one seat holding a card that goes on the pile. -// -// This is the condition, tested directly. It used to be guessed at by counting -// how many bots had passed in a row, which could not work: runBots hands the -// turn back the moment it comes round to you, so the count never got as high as -// the number of seats, and your own empty-handed pass was never in it. The guard -// never fired once. A game that can't end is worse than one that ends badly — -// and worse than either, a live game you can't finish is chips you can't cash -// out, because the cage won't let you leave a hand half-played. func (s State) stalled() bool { if s.Pending > 0 { return false // a stack is a move somebody still has to make: taking it @@ -794,11 +971,6 @@ func (s State) stalled() bool { } // discard puts a card on the pile and names the colour now in play. -// -// A wild is stamped with the colour it was played as, so the pile shows what was -// called rather than a black card and a note beside it. That stamp is undone if -// the card ever comes back out — see reshuffle, which would otherwise bleed four -// extra reds into the deck. func (s *State) discard(seat int, card Card, color Color, evs *[]Event) { if card.IsWild() { s.Color = color @@ -809,27 +981,19 @@ func (s *State) discard(seat int, card Card, color Color, evs *[]Event) { s.Discard = append(s.Discard, card) e := Event{Kind: EvPlay, Seat: seat, Card: &card, Color: s.Color, Left: len(s.Hands[seat])} *evs = append(*evs, s.mine(e)) - // No UNO event here any more. Going down to one card used to *be* the call, - // which meant nobody at this table could ever fail to make it. Now the call is - // a thing a seat does or doesn't do, and it is announced — or conspicuously not - // — by the two functions that know whether it was made. See call.go. } -// after resolves what the card just played does, and moves the turn on. It is -// the one place the rules of skip, reverse and the draw cards live. +// after resolves what the card just played does, and moves the turn on. func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) { if len(s.Hands[seat]) == 0 { - s.settle(seat, evs) + s.settle(seat, OutcomeWon, evs) return } s.Phase = PhasePlay - // A draw card. In No Mercy this doesn't land yet: it opens a stack, and the - // seat it points at gets the choice of answering it. In the normal game it - // lands where it always did. if n := card.Value.Draw(); n > 0 { if card.Value == WildRevFour { - s.flip(seat, evs) // it reverses *first*: the seat it hits is the one after that + s.flip(seat, evs) } if s.Tier.NoMercy { s.Pending += n @@ -852,17 +1016,12 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) { s.flip(seat, evs) case SkipAll: - // Everyone else loses their turn, which means it comes straight back to the - // seat that played it. The turn does not move at all. *evs = append(*evs, Event{Kind: EvSkipAll, Seat: seat}) case DiscardAll: - // Every other card of this colour goes down with it. That can empty the - // hand, which is a win — and the reason this can't lean on the empty-hand - // check at the top of the function, which already ran. s.discardAll(seat, card.Color, evs) if len(s.Hands[seat]) == 0 { - s.settle(seat, evs) + s.settle(seat, OutcomeWon, evs) return } s.advance(1) @@ -889,10 +1048,8 @@ func (s *State) flip(seat int, evs *[]Event) { s.advance(1) } -// punish makes the next seat eat a draw card and lose its turn. This is the -// normal game's rule: no stacking, because a +2 played onto a +2 is a house rule -// and the one on the box is the one this deck plays. No Mercy prints the stacking -// rule on its own box, and takes the other road out of after(). +// punish makes the next seat eat a draw card and lose its turn — the normal +// game's rule (no stacking). func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) { s.deal(victim, n, true, evs, rng) *evs = append(*evs, Event{Kind: EvSkip, Seat: victim}) @@ -900,11 +1057,8 @@ func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) { } // deal gives a seat n cards, reshuffling the discard back under the deck if it -// runs dry. The cards it hands back are the ones actually drawn — which can be -// fewer than asked for, when there is nothing left anywhere to draw. -// -// A bot's cards go face down: the event carries the count, never the card. The -// only hand whose faces cross the wire is yours. +// runs dry. Every card carries its face on the event; the web layer redacts the +// faces the viewer isn't entitled to. func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []Card { got := s.drawCards(seat, n, evs, rng) if len(got) == 0 { @@ -915,19 +1069,15 @@ func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []C kind = EvForced } e := Event{Kind: kind, Seat: seat, N: len(got), Left: len(s.Hands[seat])} - if seat == You && len(got) == 1 { + if len(got) == 1 { c := got[0] - e.Card = &c // your own card, and only yours, comes face up + e.Card = &c // one card drawn comes with its face; the web layer redacts it per viewer } *evs = append(*evs, s.mine(e)) return got } -// drawCards is deal without the announcement: cards come off the deck (with a -// reshuffle under them if it runs dry) and go into a hand, and nothing is said -// about it. deal wraps it to say the usual thing; a catch wraps it to say a -// different thing. It hands back what was actually drawn, which can be fewer -// cards than asked for when the table has nothing left to give. +// drawCards is deal without the announcement. func (s *State) drawCards(seat, n int, evs *[]Event, rng *rand.Rand) []Card { got := make([]Card, 0, n) for i := 0; i < n; i++ { @@ -944,18 +1094,15 @@ func (s *State) drawCards(seat, n int, evs *[]Event, rng *rand.Rand) []Card { return got } -// reshuffle turns the discard back into a deck, keeping the card in play on top -// of the pile. It reports whether there was anything to reshuffle. +// reshuffle turns the discard back into a deck, keeping the card in play on top. func (s *State) reshuffle(evs *[]Event, rng *rand.Rand) bool { if len(s.Discard) < 2 { - return false // nothing under the top card: the table is out of cards + return false } top := s.Discard[len(s.Discard)-1] rest := append([]Card(nil), s.Discard[:len(s.Discard)-1]...) rng.Shuffle(len(rest), func(i, j int) { rest[i], rest[j] = rest[j], rest[i] }) - // A wild goes back in as a wild. It was played as a colour, and leaving that - // colour stamped on it would quietly bleed four extra reds into the deck. for i := range rest { if rest[i].Value == WildCard || rest[i].Value == WildDrawFour { rest[i].Color = Wild @@ -967,29 +1114,55 @@ func (s *State) reshuffle(evs *[]Event, rng *rand.Rand) bool { return true } -// settle ends the game. Going out first pays the tier; anyone else going out -// takes the stake. The rake, as everywhere in this casino, comes out of the -// winnings and never out of the stake. -func (s *State) settle(winner int, evs *[]Event) { - s.Phase = PhaseDone - if winner == You { - s.Outcome = OutcomeWon - s.Payout = s.Pays() - s.Rake = s.rakeNow() - } else { - s.Outcome = OutcomeLost - s.Payout = 0 +// settle ends a hand: the winner takes the pot, less the house's rake if they are +// a human (a bot winning is the house keeping the pot, so there is nothing to +// rake). The rake, as everywhere in this casino, comes out of the winnings and +// never out of the stake. The table returns to PhaseHandOver, ready to deal again. +func (s *State) settle(winner int, outcome Outcome, evs *[]Event) { + rake := int64(0) + if !s.Seats[winner].Bot { + profit := s.Pot - s.Seats[winner].Ante + rake = s.rakeOn(profit) + s.Paid += rake } - *evs = append(*evs, Event{Kind: EvSettle, Seat: winner, Text: string(s.Outcome)}) + net := s.Pot - rake + s.Seats[winner].Stack += net + s.Seats[winner].Won = net + + s.Winner = winner + s.LastPot = s.Pot + s.Rake = rake + s.Outcome = outcome + s.Pot = 0 + s.Phase = PhaseHandOver + *evs = append(*evs, Event{Kind: EvSettle, Seat: winner, N: int(net), Text: string(outcome)}) } -// stuck ends a game nobody can move in: the deck is spent, the discard is one -// card deep, and every seat has passed. The shortest hand takes it — and a tie -// is not a win, because a win here has to be somebody actually going out. +// refund hands every seat its ante back and ends the hand a draw. It is what a +// stuck-and-level table does: nobody went out, and nobody is a length shorter +// than everybody else, so there is no winner to take the pot. +func (s *State) refund(evs *[]Event) { + for i := range s.Seats { + if s.Seats[i].Ante > 0 { + s.Seats[i].Stack += s.Seats[i].Ante + } + } + s.Winner = -1 + s.LastPot = 0 + s.Rake = 0 + s.Outcome = OutcomeTie + s.Pot = 0 + s.Phase = PhaseHandOver + *evs = append(*evs, Event{Kind: EvSettle, Seat: -1, Text: string(OutcomeTie)}) +} + +// stuck ends a hand nobody can move in: the deck is spent, the discard is one card +// deep, and every seat has passed. The shortest hand takes the pot; a tie refunds +// the antes, because a win here has to be somebody actually being ahead. func (s *State) stuck(evs *[]Event) { live := s.alive() if len(live) == 0 { - s.lose(evs) // can't happen: a mercy kill that empties the table settles first + s.refund(evs) // can't happen: a mercy kill that empties the table settles first return } best, tied := live[0], false @@ -1001,48 +1174,17 @@ func (s *State) stuck(evs *[]Event) { tied = true } } - s.Phase = PhaseDone - if best == You && !tied { - s.Outcome = OutcomeWon - s.Payout = s.Pays() - s.Rake = s.rakeNow() - } else { - s.Outcome = OutcomeStuck - s.Payout = 0 + if tied { + s.refund(evs) + return } - *evs = append(*evs, Event{Kind: EvSettle, Seat: best, Text: string(s.Outcome)}) -} - -// Pays is what going out *right now* would put back on the player's stack: the -// stake, plus the winnings, less the house's cut of the winnings. -// -// It exists because the felt quotes this number while the game is still running, -// and settle() is the only other thing that works it out. Hangman learned this -// the hard way: two sums drift, and the table ends up advertising a payout it -// doesn't honour. So settle calls this rather than doing it again. -func (s State) Pays() int64 { - total := int64(math.Floor(float64(s.Bet) * s.Tier.Base)) - if total < s.Bet { - total = s.Bet - } - profit := total - s.Bet - if profit > 0 { - profit -= s.rakeOn(profit) - } - return s.Bet + profit -} - -// rakeNow is the other half of what Pays works out: the house's cut of a win -// taken right now. -func (s State) rakeNow() int64 { - total := int64(math.Floor(float64(s.Bet) * s.Tier.Base)) - if total <= s.Bet { - return 0 - } - return s.rakeOn(total - s.Bet) + s.settle(best, OutcomeStuck, evs) } func (s State) rakeOn(profit int64) int64 { + if profit <= 0 { + return 0 + } rake := int64(math.Floor(float64(profit) * s.RakePct)) if rake < 0 { return 0 @@ -1050,33 +1192,20 @@ func (s State) rakeOn(profit int64) int64 { return rake } -// Net is what the game did to the player's stack. -func (s State) Net() int64 { - if s.Phase != PhaseDone { - return 0 - } - return s.Payout - s.Bet -} - -// Playable reports which cards of your hand can legally go on the pile. The -// browser lights these up: being shown what you can play is the game teaching -// you, and the server still decides every move regardless. -// -// While you're sitting on a card you just drew, that card is the only one you -// may play — so it is the only one that lights up. -func (s State) Playable() []int { - if s.Phase == PhaseDone || s.Turn != You { +// Playable reports which cards of a seat's hand can legally go on the pile. The +// browser lights these up for the seat that is looking; the server still decides +// every move regardless. +func (s State) Playable(seat int) []int { + if !s.playing() || s.Turn != seat { return nil } - hand := s.Hands[You] + hand := s.Hands[seat] if s.Phase == PhaseDrawn { if len(hand) > 0 && hand[len(hand)-1].CanPlayOn(s.top(), s.Color) { return []int{len(hand) - 1} } return nil } - // Under a stack, the only cards that light up are the ones that answer it. - // Everything else in the hand is dead until the bill is paid. if s.Phase == PhaseStack { var out []int for i, c := range hand { @@ -1095,8 +1224,8 @@ func (s State) Playable() []int { return out } -// Counts is how many cards each seat holds — what the browser gets instead of -// the bots' hands. +// Counts is how many cards each seat holds — what the browser gets instead of the +// other seats' hands. func (s State) Counts() []int { out := make([]int, len(s.Hands)) for i := range s.Hands { @@ -1135,20 +1264,15 @@ func (s *State) pop() (Card, bool) { return c, true } -// seatAt is the seat n *live* places round from the one whose turn it is. -// -// A seat the mercy rule has killed is not there any more: it is stepped over, not -// landed on and skipped. So this counts living seats rather than doing the -// arithmetic on the index — which is the same thing in a normal game, where -// nobody is ever out, and the only thing that keeps a No Mercy table from -// handing the turn to a corpse. +// seatAt is the seat n *live* places round from the one whose turn it is. A seat +// not in the hand is stepped over, not landed on and skipped. func (s State) seatAt(n int) int { - seats := len(s.Hands) + seats := len(s.Seats) at := s.Turn for moved := 0; moved < n; { at = ((at+s.Dir)%seats + seats) % seats if at == s.Turn && !s.live(at) { - return at // nobody left alive to hand it to; the caller ends the game + return at // nobody left alive to hand it to; the caller ends the hand } if s.live(at) { moved++ @@ -1160,12 +1284,11 @@ func (s State) seatAt(n int) int { // advance moves the turn on n places. func (s *State) advance(n int) { s.Turn = s.seatAt(n) } -// minOpponent is the smallest hand at the table that isn't this seat's — how -// close the bot is to being beaten, which is the only thing it plays around. +// minOpponent is the smallest hand at the table that isn't this seat's. func (s State) minOpponent(seat int) int { min := -1 for i := range s.Hands { - if i == seat { + if i == seat || !s.live(i) { continue } if min < 0 || len(s.Hands[i]) < min { @@ -1183,18 +1306,15 @@ func (s State) clone() State { hands[i] = append([]Card(nil), h...) } s.Hands = hands + s.Seats = append([]Seat(nil), s.Seats...) s.Deck = append([]Card(nil), s.Deck...) s.Discard = append([]Card(nil), s.Discard...) - s.Bots = append([]string(nil), s.Bots...) s.Out = append([]bool(nil), s.Out...) s.Called = append([]bool(nil), s.Called...) return s } -// stepRNG is the generator for one step of the game. The seed is the game's; -// the step number is what stops every move from replaying the same numbers. -// Mixing with the golden ratio's odd 64-bit constant keeps consecutive steps -// from producing streams that share a bit pattern. +// stepRNG is the generator for one step of the game. func stepRNG(seed1, seed2, step uint64) *rand.Rand { return rand.New(rand.NewPCG(seed1, seed2^(step*0x9E3779B97F4A7C15))) } diff --git a/internal/games/uno/uno_test.go b/internal/games/uno/uno_test.go index c88a1a9..968746f 100644 --- a/internal/games/uno/uno_test.go +++ b/internal/games/uno/uno_test.go @@ -8,19 +8,38 @@ import ( const rake = 0.05 +// You is the human's seat in the solo tests, a test-local alias for what the +// engine no longer hardcodes: a table is a list of seats, and seat zero being the +// human is a convention only these fixtures keep. +const You = 0 + func duel() Tier { t, _ := TierBySlug("duel"); return t } func full() Tier { t, _ := TierBySlug("full"); return t } func table() Tier { t, _ := TierBySlug("table"); return t } -// deal starts a game, failing the test if it can't. -func deal(t *testing.T, tier Tier, bet int64, seed uint64) State { +// openSolo opens a solo table (one human, the tier's bots) without dealing. +func openSolo(t *testing.T, tier Tier, seed uint64) State { t.Helper() - s, evs, err := New(bet, tier, rake, seed, 0xC0FFEE) + s, _, err := New(tier, SoloSeats(tier, tier.Bots, 1000), rake, seed, 0xC0FFEE) if err != nil { t.Fatalf("New: %v", err) } - if len(evs) != 1 || evs[0].Kind != EvDeal { - t.Fatalf("New should deal exactly one event, got %+v", evs) + return s +} + +// deal opens a solo table and deals the first hand, so the human is to act. +func deal(t *testing.T, tier Tier, seed uint64) State { + t.Helper() + s := openSolo(t, tier, seed) + s, evs, err := ApplyMove(s, You, Move{Kind: MoveDeal}) + if err != nil { + t.Fatalf("deal: %v", err) + } + if !hasKind(evs, EvDeal) { + t.Fatalf("the deal emitted no deal event: %+v", evs) + } + if s.Turn != You { + t.Fatalf("the human should act first at a solo table, turn is %d", s.Turn) } return s } @@ -38,11 +57,6 @@ func census(s State) map[Card]int { m[c]++ } for _, c := range s.Discard { - // A wild is stamped with the colour it was played as while it sits on the - // pile, so it counts as the wild it really is. This asks the face, rather - // than listing the wilds: No Mercy prints four more of them, and a census - // that didn't know about them would count a played roulette as a red card - // and report a deck that balances while the cards don't. if c.Value.Wild() { c.Color = Wild } @@ -76,7 +90,7 @@ func TestNewDeckIsADeck(t *testing.T) { } func TestNewDeals(t *testing.T) { - s := deal(t, full(), 100, 7) + s := deal(t, full(), 7) if len(s.Hands) != 4 { t.Fatalf("full house is four seats, got %d", len(s.Hands)) } @@ -85,21 +99,31 @@ func TestNewDeals(t *testing.T) { t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize) } } - if len(s.Bots) != 3 { - t.Fatalf("want three bot names, got %v", s.Bots) + bots := 0 + for _, seat := range s.Seats { + if seat.Bot { + bots++ + if seat.Name == "" { + t.Error("a bot seat has no name") + } + } } - if s.Turn != You { - t.Errorf("you play first, turn is %d", s.Turn) + if bots != 3 { + t.Fatalf("want three bots, got %d", bots) } if got := total(census(s)); got != 108 { t.Fatalf("the deal lost cards: %d of 108", got) } + // Every seat anted, so the pot is one ante per seat. + if want := s.Tier.Ante * int64(len(s.Seats)); s.Pot != want { + t.Errorf("pot is %d, want %d (one ante each)", s.Pot, want) + } } -// The card turned over to start is never an action card — see New. +// The card turned over to start is never an action card — see dealHand. func TestOpeningCardIsANumber(t *testing.T) { for seed := uint64(0); seed < 300; seed++ { - s := deal(t, table(), 50, seed) + s := deal(t, table(), seed) if s.Top().Value.Action() { t.Fatalf("seed %d opened on %v", seed, s.Top()) } @@ -111,12 +135,9 @@ func TestOpeningCardIsANumber(t *testing.T) { // ---- the rules ------------------------------------------------------------ -// rig builds a state by hand, so a rule can be tested without hunting a seed -// that happens to deal it. -// -// The deck is the rest of the deck: every card not in a hand and not the one in -// play. So a rigged game still holds 108 cards, and the census invariant means -// something in these tests too. +// rig builds a live hand by hand, so a rule can be tested without hunting a seed +// that happens to deal it. Every seat has anted, so the pot is set and a win +// settles for real. func rig(hands [][]Card, top Card, color Color) State { left := map[Card]int{} for _, c := range NewDeck() { @@ -137,30 +158,38 @@ func rig(hands [][]Card, top Card, color Color) State { var deck []Card for _, c := range NewDeck() { - key := c - if left[key] > 0 { - left[key]-- + if left[c] > 0 { + left[c]-- deck = append(deck, c) } } + ante := full().Ante + seats := make([]Seat, len(hands)) + for i := range seats { + // The stack is what is left after this seat anted: a real deal moves the ante + // out of the stack and into the pot, so a refund or a win returns it here. + seats[i] = Seat{Name: botPool[i], Bot: i != You, Stack: 1000 - ante, Ante: ante} + } + seats[You].Name = "You" return State{ - Tier: full(), Hands: hands, Discard: []Card{top}, Color: color, - Deck: deck, Dir: 1, Turn: You, Phase: PhasePlay, - Bet: 100, RakePct: rake, Seed1: 1, Seed2: 2, + Tier: full(), Seats: seats, Hands: hands, Discard: []Card{top}, Color: color, + Deck: deck, Dir: 1, Turn: You, Dealer: len(hands) - 1, Phase: PhasePlay, + Pot: ante * int64(len(hands)), Winner: -1, + Out: make([]bool, len(hands)), Called: make([]bool, len(hands)), + RakePct: rake, Seed1: 1, Seed2: 2, } } func TestPlayMustMatch(t *testing.T) { s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red) - if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay { + if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay { t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err) } } func TestPlayMatchesFaceOrColor(t *testing.T) { - // Same face, different colour: legal. s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red) - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("a blue 9 on a red 9 is legal: %v", err) } @@ -174,22 +203,20 @@ func TestPlayMatchesFaceOrColor(t *testing.T) { func TestWildNeedsAColor(t *testing.T) { s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red) - if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor { + if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor { t.Fatalf("a wild with no colour should be refused, got %v", err) } - if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor { + if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor { t.Fatalf("naming 'wild' is not naming a colour, got %v", err) } } func TestWildNamesTheColor(t *testing.T) { s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red) - next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Green}) + next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Green}) if err != nil { t.Fatalf("play wild: %v", err) } - // The bot moved after us, so the colour in play is whatever it left behind — - // what we can check is that the wild itself went down as green. top := next.Discard if len(top) < 2 { t.Fatalf("expected the wild and the bot's card on the pile: %v", top) @@ -200,10 +227,9 @@ func TestWildNamesTheColor(t *testing.T) { } func TestDrawTwoHitsTheNextSeat(t *testing.T) { - // Two seats, so the +2 lands on the bot and the turn comes straight back. s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red) s.Tier = duel() - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play +2: %v", err) } @@ -224,7 +250,7 @@ func TestDrawTwoHitsTheNextSeat(t *testing.T) { func TestReverseIsASkipHeadsUp(t *testing.T) { s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red) s.Tier = duel() - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play reverse: %v", err) } @@ -243,15 +269,13 @@ func TestReverseIsASkipHeadsUp(t *testing.T) { } func TestReverseTurnsTheTableAround(t *testing.T) { - // Every bot holds a red card, so each of them can play the moment the turn - // reaches it — which is what makes the *order* they play in observable. s := rig([][]Card{ {{Red, Reverse}, {Red, One}}, {{Red, Five}, {Blue, Six}}, {{Red, Six}, {Green, Six}}, {{Red, Seven}, {Yellow, Six}}, }, Card{Red, Nine}, Red) - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play reverse: %v", err) } @@ -264,7 +288,6 @@ func TestReverseTurnsTheTableAround(t *testing.T) { if next.Turn != You { t.Errorf("the bots should have played round to you, turn is %d", next.Turn) } - // The table now runs anticlockwise: seat 3 plays, then 2, then 1. var order []int for _, e := range evs { if e.Kind == EvPlay && e.Seat != You { @@ -277,15 +300,13 @@ func TestReverseTurnsTheTableAround(t *testing.T) { } func TestSkipSkips(t *testing.T) { - // Both bots hold a playable red, so the only reason either of them doesn't - // play is that it wasn't asked to. s := rig([][]Card{ {{Red, Skip}, {Red, One}}, {{Red, Five}, {Blue, Six}}, {{Red, Six}, {Green, Six}}, }, Card{Red, Nine}, Red) s.Tier = table() - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play skip: %v", err) } @@ -310,7 +331,7 @@ func TestSkipSkips(t *testing.T) { func TestDrawnPlayableWaitsForYou(t *testing.T) { s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays - next, evs, err := ApplyMove(s, Move{Kind: MoveDraw}) + next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } @@ -323,18 +344,16 @@ func TestDrawnPlayableWaitsForYou(t *testing.T) { if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) { t.Fatalf("your own drawn card comes face up: %+v", evs[0]) } - if got := next.Playable(); len(got) != 1 || got[0] != 1 { + if got := next.Playable(You); len(got) != 1 || got[0] != 1 { t.Errorf("the drawn card, and only it, is playable: %v", got) } - // You may not play the *other* card instead — drawing would otherwise be a - // free look with no cost. - if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow { + if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow { t.Fatalf("only the drawn card may be played, got %v", err) } - if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustPlayNow { + if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustPlayNow { t.Fatalf("you can't draw twice, got %v", err) } - after, _, err := ApplyMove(next, Move{Kind: MovePass}) + after, _, err := ApplyMove(next, You, Move{Kind: MovePass}) if err != nil { t.Fatalf("pass: %v", err) } @@ -349,7 +368,7 @@ func TestDrawnPlayableWaitsForYou(t *testing.T) { func TestUnplayableDrawPassesTheTurn(t *testing.T) { s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9 - next, evs, err := ApplyMove(s, Move{Kind: MoveDraw}) + next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } @@ -363,69 +382,78 @@ func TestUnplayableDrawPassesTheTurn(t *testing.T) { func TestPassOnlyAfterADraw(t *testing.T) { s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) - if _, _, err := ApplyMove(s, Move{Kind: MovePass}); err != ErrCantPass { + if _, _, err := ApplyMove(s, You, Move{Kind: MovePass}); err != ErrCantPass { t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err) } } // dead is a table nobody can move at: the deck is spent, the discard is one card -// deep so there is nothing to reshuffle out of, and not a seat holds a card that -// goes on the pile. Every seat can only pass, forever. +// deep, and not a seat holds a card that goes on the pile. func dead(hands [][]Card) State { s := rig(hands, Card{Red, Nine}, Red) s.Deck = nil return s } -// The game has to end here. It used to not: the stuck guard counted how many -// bots had passed in a row and asked for more of them than there are seats, so -// it never fired once, and a dead table just handed the turn round and round. -// That is a game the player cannot finish — and a game they cannot finish is -// chips they cannot cash out, because the cage won't let them leave a live hand. +// A dead table ends the hand rather than passing the turn round forever. It no +// longer ends the *session* — a shared table plays another hand — so it lands on +// PhaseHandOver, not PhaseDone. func TestDeadTableEnds(t *testing.T) { - s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) + s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) // level: one card each - next, evs, err := ApplyMove(s, Move{Kind: MoveDraw}) + next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } - if next.Phase != PhaseDone { - t.Fatalf("nobody can move and there is nothing to draw: the game is over, not %q", next.Phase) + if next.playing() { + t.Fatalf("nobody can move and there is nothing to draw: the hand is over, not %q", next.Phase) } - if next.Outcome != OutcomeStuck { - t.Errorf("a tie on the shortest hand is nobody going out: %q", next.Outcome) + if next.Phase != PhaseHandOver { + t.Fatalf("a dead hand at a shared table returns to handover, not %q", next.Phase) } - if next.Payout != 0 { - t.Errorf("a stuck game pays nothing, not %d", next.Payout) + if next.Outcome != OutcomeTie { + t.Errorf("level on the shortest hand is a tie, got %q", next.Outcome) + } + // A tie hands the antes back: every seat is whole again. + for i := range next.Seats { + if next.Seats[i].Stack != 1000 { + t.Errorf("seat %d wasn't refunded: stack %d, want 1000", i, next.Seats[i].Stack) + } } if !hasKind(evs, EvSettle) { - t.Errorf("the table has to be told it's over: %+v", evs) + t.Errorf("the table has to be told the hand is over: %+v", evs) } } -// And the shortest hand takes it, which is the one way a stuck table still pays. +// And the shortest hand takes the pot, which is the one way a stuck table pays. func TestDeadTablePaysTheShortestHand(t *testing.T) { s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}}) + pot := s.Pot + before := s.Seats[You].Stack - next, _, err := ApplyMove(s, Move{Kind: MoveDraw}) + next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } - if next.Outcome != OutcomeWon { - t.Fatalf("one card against two is a win: %q", next.Outcome) + if next.Outcome != OutcomeStuck || next.Winner != You { + t.Fatalf("one card against two is a win for you: outcome %q winner %d", next.Outcome, next.Winner) } - if next.Payout != s.Pays() { - t.Errorf("payout %d, quoted %d — the felt has to honour what it advertised", next.Payout, s.Pays()) + profit := pot - s.Tier.Ante + wantRake := int64(float64(profit) * rake) + wantWon := pot - wantRake + if next.Seats[You].Won != wantWon { + t.Errorf("you took %d, want %d (pot %d less rake %d)", next.Seats[You].Won, wantWon, pot, wantRake) + } + if next.Seats[You].Stack != before+wantWon { + t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon) } } func TestReshuffleRebuildsTheDeck(t *testing.T) { s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) - // An empty deck, and a discard with something under the top card to become one. - // The buried wild went down as green; it has to come back as a wild. s.Deck = nil s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}} - next, evs, err := ApplyMove(s, Move{Kind: MoveDraw}) + next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw on an empty deck: %v", err) } @@ -449,89 +477,64 @@ func TestReshuffleRebuildsTheDeck(t *testing.T) { } } -// ---- the money ------------------------------------------------------------ +// ---- the pot -------------------------------------------------------------- -// The rule every game in this casino has had to be taught: the number the felt -// quotes and the number the settle lands on are one function, not two. -func TestQuoteIsThePayout(t *testing.T) { - for _, tier := range Tiers { - s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) - s.Tier = tier - s.Hands = make([][]Card, tier.Bots+1) - s.Hands[You] = []Card{{Red, Three}} - for i := 1; i <= tier.Bots; i++ { - s.Hands[i] = []Card{{Green, Five}, {Green, Six}} - } - quoted := s.Pays() +// The winner takes the pot, and the house's rake comes out of the winnings, never +// out of a seat's own ante. +func TestWinnerTakesThePotLessRake(t *testing.T) { + s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}, {{Blue, One}, {Blue, Two}}}, Card{Red, Nine}, Red) + pot := s.Pot + before := s.Seats[You].Stack - next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) // your last card - if err != nil { - t.Fatalf("%s: go out: %v", tier.Slug, err) - } - if next.Outcome != OutcomeWon { - t.Fatalf("%s: playing your last card wins, got %q", tier.Slug, next.Outcome) - } - if next.Payout != quoted { - t.Errorf("%s: the felt quoted %d, the house paid %d", tier.Slug, quoted, next.Payout) - } - if next.Net() != quoted-s.Bet { - t.Errorf("%s: net is %d, want %d", tier.Slug, next.Net(), quoted-s.Bet) - } - } -} - -// The rake comes out of the winnings, never the stake. -// -// The arithmetic is derived from the tier rather than written down. It used to be -// written down — payout 214, rake 6 — and those numbers were the 2.2× duel. When -// the tiers were re-measured and repriced, this test failed on a rake that was -// perfectly correct, which is a test asserting a *price* while claiming to assert -// a *rule*. The rule is: the house takes its cut of the profit and never touches -// the stake. That holds at any multiple. -func TestRakeIsOnWinningsOnly(t *testing.T) { - s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red) - s.Tier = duel() - s.Bet = 100 - - gross := int64(float64(s.Bet) * s.Tier.Base) // what the tier pays back, before the house - profit := gross - s.Bet - wantRake := int64(float64(profit) * rake) - wantPayout := s.Bet + profit - wantRake - - next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) // your last card if err != nil { t.Fatalf("go out: %v", err) } - if next.Payout != wantPayout { - t.Errorf("payout %d, want %d (%d stake + %d winnings - %d rake)", - next.Payout, wantPayout, s.Bet, profit, wantRake) + if next.Outcome != OutcomeWon || next.Winner != You { + t.Fatalf("playing your last card wins: outcome %q winner %d", next.Outcome, next.Winner) } + profit := pot - s.Tier.Ante + wantRake := int64(float64(profit) * rake) + wantWon := pot - wantRake if next.Rake != wantRake { - t.Errorf("rake %d, want %d — and never a penny of the %d stake", - next.Rake, wantRake, s.Bet) + t.Errorf("rake %d, want %d — and never a penny of an ante", next.Rake, wantRake) } - if next.Net() != wantPayout-s.Bet { - t.Errorf("net %d, want %d", next.Net(), wantPayout-s.Bet) + if next.LastPot != pot { + t.Errorf("last pot %d, want %d", next.LastPot, pot) + } + if next.Seats[You].Stack != before+wantWon { + t.Errorf("your stack is %d, want %d (+%d)", next.Seats[You].Stack, before+wantWon, wantWon) + } + if next.Paid != wantRake { + t.Errorf("the session rake tally is %d, want %d", next.Paid, wantRake) } } -func TestLosingPaysNothingAndIsNotCharged(t *testing.T) { - // The bot holds one card that plays on the pile, so it goes out the moment the - // turn reaches it. +// A bot winning rakes nothing: the house already keeps the whole pot when its own +// seat takes it, so there is nothing to charge. +func TestABotWinningRakesNothing(t *testing.T) { + // The bot at seat 1 holds one card that plays; it goes out the moment the turn + // reaches it. s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red) s.Tier = duel() - next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + pot := s.Pot + + next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play: %v", err) } - if next.Outcome != OutcomeLost { - t.Fatalf("the bot went out, so you lost: %q", next.Outcome) + if next.Outcome != OutcomeWon || next.Winner != 1 { + t.Fatalf("the bot went out: outcome %q winner %d", next.Outcome, next.Winner) } - if next.Payout != 0 || next.Rake != 0 { - t.Errorf("a loss pays nothing and is charged nothing: payout %d rake %d", next.Payout, next.Rake) + if next.Rake != 0 { + t.Errorf("a bot winning rakes nothing, got %d", next.Rake) } - if next.Net() != -s.Bet { - t.Errorf("a loss costs the stake and no more: %d", next.Net()) + if next.Seats[1].Won != pot { + t.Errorf("the bot took %d, want the whole pot %d", next.Seats[1].Won, pot) + } + // You anted and lost it: your stack is down exactly one ante. + if next.Seats[You].Stack != 1000-s.Tier.Ante { + t.Errorf("your stack is %d, want %d (one ante gone)", next.Seats[You].Stack, 1000-s.Tier.Ante) } last := evs[len(evs)-1] if last.Kind != EvSettle || last.Seat != 1 { @@ -539,51 +542,81 @@ func TestLosingPaysNothingAndIsNotCharged(t *testing.T) { } } -func TestNoMoveAfterItIsOver(t *testing.T) { +// A hand ending returns the table to handover, ready to deal again — it does not +// take a hand move. +func TestNoHandMoveBetweenHands(t *testing.T) { s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red) s.Tier = duel() - done, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("go out: %v", err) } - if _, _, err := ApplyMove(done, Move{Kind: MoveDraw}); err != ErrGameOver { - t.Fatalf("a finished game takes no more moves, got %v", err) + if over.Phase != PhaseHandOver { + t.Fatalf("a finished hand returns to handover, got %q", over.Phase) + } + if _, _, err := ApplyMove(over, You, Move{Kind: MoveDraw}); err != ErrNoHand { + t.Fatalf("no hand is in progress between hands, got %v", err) } } -func TestBadBet(t *testing.T) { - if _, _, err := New(0, duel(), rake, 1, 2); err != ErrBadBet { - t.Fatalf("want ErrBadBet, got %v", err) +// You can deal the next hand, ante again, and play on — the session shape. +func TestDealTheNextHand(t *testing.T) { + s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red) + s.Tier = duel() + over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("go out: %v", err) + } + again, evs, err := ApplyMove(over, You, Move{Kind: MoveDeal}) + if err != nil { + t.Fatalf("deal the next hand: %v", err) + } + if again.HandNo != over.HandNo+1 { + t.Errorf("hand number didn't advance: %d then %d", over.HandNo, again.HandNo) + } + if !again.playing() { + t.Fatalf("the next hand should be live, phase %q", again.Phase) + } + if !hasKind(evs, EvAnte) || !hasKind(evs, EvDeal) { + t.Errorf("the deal antes and turns a card: %+v", evs) + } +} + +func TestBadBuyIn(t *testing.T) { + if _, _, err := New(duel(), SoloSeats(duel(), 1, 10), rake, 1, 2); err != ErrBadBuyIn { + t.Fatalf("a buy-in under the minimum should be refused, got %v", err) } } // ---- the whole game ------------------------------------------------------- -// playOut plays a game to its end with a simple strategy: play the first legal -// card, otherwise draw, otherwise pass. It asserts the invariants at every step. +// playOut plays one hand to its end with a simple strategy: play the first legal +// card, take a stack you can't answer, otherwise draw, otherwise pass. func playOut(t *testing.T, s State, maxTurns int) State { t.Helper() - for turn := 0; s.Phase != PhaseDone; turn++ { + for turn := 0; s.playing(); turn++ { if turn > maxTurns { - t.Fatalf("the game never ended in %d turns", maxTurns) + t.Fatalf("the hand never ended in %d turns", maxTurns) } if s.Turn != You { t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn) } var m Move - if p := s.Playable(); len(p) > 0 { + if p := s.Playable(You); len(p) > 0 { m = Move{Kind: MovePlay, Index: p[0]} if s.Hands[You][p[0]].IsWild() { m.Color = Green } + } else if s.Phase == PhaseStack { + m = Move{Kind: MoveTake} } else if s.Phase == PhaseDrawn { m = Move{Kind: MovePass} } else { m = Move{Kind: MoveDraw} } - next, evs, err := ApplyMove(s, m) + next, evs, err := ApplyMove(s, You, m) if err != nil { t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase) } @@ -598,13 +631,6 @@ func playOut(t *testing.T, s State, maxTurns int) State { t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want) } } - // No event ever names a bot's card. That is the hole card of this game, and - // it is most of the deck. - for _, e := range evs { - if (e.Kind == EvDraw || e.Kind == EvForced) && e.Seat != You && e.Card != nil { - t.Fatalf("turn %d: a bot's drawn card crossed the wire: %+v", turn, e) - } - } s = next } return s @@ -622,72 +648,54 @@ func deckCount(c Card) int { } } -// A hundred games, played out, with the invariants checked at every step. This -// is the test that would have caught a deck that leaks cards through the -// reshuffle, a turn the bots don't hand back, or a game that can't end. +// A hundred hands, played out, with the invariants checked at every step. func TestGamesPlayOut(t *testing.T) { - wins, losses, stuck := 0, 0, 0 + yous, others, ties := 0, 0, 0 for seed := uint64(0); seed < 100; seed++ { tier := Tiers[seed%3] - end := playOut(t, deal(t, tier, 100, seed), 500) - switch end.Outcome { - case OutcomeWon: - wins++ - if end.Payout != end.Pays() { - t.Fatalf("seed %d: paid %d, quoted %d", seed, end.Payout, end.Pays()) - } - case OutcomeLost: - losses++ - case OutcomeStuck: - stuck++ - default: - t.Fatalf("seed %d ended as %q", seed, end.Outcome) + end := playOut(t, deal(t, tier, seed), 500) + if end.Phase != PhaseHandOver { + t.Fatalf("seed %d ended in phase %q", seed, end.Phase) } - if len(end.Hands[end.winnerSeat()]) != 0 && end.Outcome != OutcomeStuck { + switch { + case end.Winner == You: + yous++ + case end.Winner >= 0: + others++ + case end.Outcome == OutcomeTie: + ties++ + default: + t.Fatalf("seed %d ended with winner %d outcome %q", seed, end.Winner, end.Outcome) + } + if end.Winner >= 0 && end.Outcome == OutcomeWon && len(end.Hands[end.Winner]) != 0 { t.Fatalf("seed %d: the winner is still holding cards", seed) } } - // Playing the first legal card is a poor strategy against bots that hold their - // +4s back, so this is not a fairness assertion — it's a check that both - // outcomes actually happen. A table that never pays is a bug in the bots. - if wins == 0 || losses == 0 { - t.Fatalf("100 games gave %d wins, %d losses, %d stuck — one side never happens", wins, losses, stuck) + if yous == 0 || others == 0 { + t.Fatalf("100 hands gave %d to you, %d to others, %d tied — one side never happens", yous, others, ties) } - t.Logf("100 games: %d won, %d lost, %d stuck", wins, losses, stuck) + t.Logf("100 hands: %d to you, %d to others, %d tied", yous, others, ties) } -// winnerSeat is the seat the settle event named — only used by the tests. -func (s State) winnerSeat() int { - best := 0 - for i := range s.Hands { - if len(s.Hands[i]) < len(s.Hands[best]) { - best = i - } - } - return best -} - -// The same seed deals the same game and the bots make the same choices — which -// is what lets a disputed game be replayed exactly as it fell. +// The same seed deals the same hand and the bots make the same choices. func TestReplaysFromTheSeed(t *testing.T) { - a := playOut(t, deal(t, full(), 250, 42), 500) - b := playOut(t, deal(t, full(), 250, 42), 500) + a := playOut(t, deal(t, full(), 42), 500) + b := playOut(t, deal(t, full(), 42), 500) ja, _ := json.Marshal(a) jb, _ := json.Marshal(b) if string(ja) != string(jb) { t.Fatal("the same seed played the same way gave two different games") } - if a.Outcome == "" { + if a.Winner < 0 && a.Outcome != OutcomeTie { t.Fatal("the replay didn't finish") } } -// A game in progress survives a redeploy: it is a plain value, so it round-trips -// through the JSON it is stored as. +// A game in progress survives a redeploy: it round-trips through its JSON. func TestStateSurvivesStorage(t *testing.T) { - s := deal(t, table(), 100, 9) - s, _, err := ApplyMove(s, Move{Kind: MoveDraw}) + s := deal(t, table(), 9) + s, _, err := ApplyMove(s, You, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } @@ -704,12 +712,10 @@ func TestStateSurvivesStorage(t *testing.T) { if string(again) != string(blob) { t.Fatal("a stored game came back different") } - // And it plays on from there. playOut(t, back, 500) } -// A move the engine refuses leaves the caller's state exactly as it was — no -// card half-played, no turn half-passed. +// A move the engine refuses leaves the caller's state exactly as it was. func TestARefusedMoveChangesNothing(t *testing.T) { s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red) before, _ := json.Marshal(s) @@ -721,7 +727,7 @@ func TestARefusedMoveChangesNothing(t *testing.T) { {Kind: MovePass}, // nothing drawn {Kind: "shuffle-the-deck-in-my-favour"}, // no } { - if _, _, err := ApplyMove(s, m); err == nil { + if _, _, err := ApplyMove(s, You, m); err == nil { t.Fatalf("%+v should have been refused", m) } } @@ -731,13 +737,12 @@ func TestARefusedMoveChangesNothing(t *testing.T) { } } -// The bots choose. Two different seeds should not play the same bot game, or the -// bot is a lookup table you can memorise. +// The bots choose. Two different seeds should not play the same game. func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) { same := 0 for seed := uint64(0); seed < 20; seed++ { - a := playOut(t, deal(t, duel(), 100, seed), 500) - b := playOut(t, deal(t, duel(), 100, seed+1000), 500) + a := playOut(t, deal(t, duel(), seed), 500) + b := playOut(t, deal(t, duel(), seed+1000), 500) if len(a.Discard) == len(b.Discard) { same++ } @@ -747,8 +752,6 @@ func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) { } } -// botPick holds its +4 back while it's comfortable, and reaches for it when -// somebody is about to go out. func TestBotSavesTheDrawFour(t *testing.T) { hand := []Card{{Wild, WildDrawFour}, {Red, Five}} top, color := Card{Red, Nine}, Red @@ -781,7 +784,6 @@ func TestBotPicksItsBestColor(t *testing.T) { if got := botColor(hand, rng); got != Blue { t.Errorf("the bot holds two blues: it should call blue, got %v", got) } - // A hand of nothing but wilds still has to name something. for i := 0; i < 20; i++ { if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() { t.Fatalf("botColor named %v, which is not a colour", got) diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go index f88bb9e..bc4bc58 100644 --- a/internal/web/games_pages.go +++ b/internal/web/games_pages.go @@ -124,16 +124,21 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart) mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer) - mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart) + mux.HandleFunc("POST /api/games/uno/sit", s.handleUnoSit) mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove) + mux.HandleFunc("POST /api/games/uno/leave", s.handleUnoLeave) + mux.HandleFunc("GET /api/games/uno/tables", s.handleUnoLobby) + mux.HandleFunc("GET /api/games/uno/stream", s.handleTableStream) + mux.HandleFunc("GET /api/games/uno/chat", s.handleTableChat) + mux.HandleFunc("POST /api/games/uno/say", s.handleTableSay) mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit) mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove) mux.HandleFunc("POST /api/games/holdem/leave", s.handleHoldemLeave) mux.HandleFunc("GET /api/games/holdem/tables", s.handleHoldemLobby) - mux.HandleFunc("GET /api/games/holdem/stream", s.handleHoldemStream) - mux.HandleFunc("GET /api/games/holdem/chat", s.handleHoldemChat) - mux.HandleFunc("POST /api/games/holdem/say", s.handleHoldemSay) + mux.HandleFunc("GET /api/games/holdem/stream", s.handleTableStream) + mux.HandleFunc("GET /api/games/holdem/chat", s.handleTableChat) + mux.HandleFunc("POST /api/games/holdem/say", s.handleTableSay) } // requirePlayer sends an anonymous visitor to sign in and comes back here after. diff --git a/internal/web/games_play.go b/internal/web/games_play.go index cf7ab64..a29b611 100644 --- a/internal/web/games_play.go +++ b/internal/web/games_play.go @@ -290,11 +290,33 @@ func (s *Server) table(user string) (tableView, error) { tv := viewTrivia(g, time.Now()) v.Trivia = &tv case gameUno: + // A seated UNO player's cards are in game_tables, not here — this row is only + // their occupancy claim. Load the table and render it as their own seat sees it. + if live.TableID == "" { + return s.dropUnreadable(user, v, fmt.Errorf("uno row with no table")) + } + t, tableSeats, err := storage.LoadTable(live.TableID) + if errors.Is(err, storage.ErrNoSuchTable) { + return s.dropUnreadable(user, v, fmt.Errorf("uno table %s gone", live.TableID)) + } + if err != nil { + return tableView{}, err + } + _, seat, err := storage.PlayerSeat(user) + if err != nil { + return tableView{}, err + } var g uno.State - if err := json.Unmarshal(live.State, &g); err != nil { + if err := json.Unmarshal(t.State, &g); err != nil { return s.dropUnreadable(user, v, err) } - uv := viewUno(g) + uv := viewUno(g, seat) + for _, ts := range tableSeats { + if ts.Seat == seat { + uv.BoughtIn = ts.Staked + break + } + } v.Uno = &uv case gameHoldem: // A seated hold'em player's cards are in game_tables, not here — this row is diff --git a/internal/web/games_table.go b/internal/web/games_table.go index 4723608..d94a6c6 100644 --- a/internal/web/games_table.go +++ b/internal/web/games_table.go @@ -19,17 +19,25 @@ import ( // ---- the lobby ------------------------------------------------------------- -// handleHoldemLobby lists the hold'em tables with a seat going spare. A table -// with every chair taken is not shown, because a lobby you cannot join from is -// just a list; bots keep every open table populated, so there is always -// something to sit down at. +// handleHoldemLobby and handleUnoLobby list the tables of their game with a seat +// going spare. A table with every chair taken is not shown, because a lobby you +// cannot join from is just a list; bots keep every open table populated, so there +// is always something to sit down at. func (s *Server) handleHoldemLobby(w http.ResponseWriter, r *http.Request) { + s.lobby(w, r, gameHoldem) +} + +func (s *Server) handleUnoLobby(w http.ResponseWriter, r *http.Request) { + s.lobby(w, r, gameUno) +} + +func (s *Server) lobby(w http.ResponseWriter, r *http.Request, game string) { if _, ok := s.player(w, r); !ok { return } - tables, err := storage.LobbyTables(gameHoldem, 50) + tables, err := storage.LobbyTables(game, 50) if err != nil { - slog.Error("games: holdem lobby", "err", err) + slog.Error("games: lobby", "game", game, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } @@ -58,7 +66,7 @@ const streamPing = 25 * time.Second // table the player is at. After that it only ever reads its channel. Holding a // query open for the life of a stream would hold the single pooled connection for // the life of a stream, and one idle subscriber would take the whole site down. -func (s *Server) handleHoldemStream(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleTableStream(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return @@ -117,7 +125,7 @@ func (s *Server) handleHoldemStream(w http.ResponseWriter, r *http.Request) { // handleHoldemChat reads the recent rail of a player's table, oldest first, with // their own lines flagged so the felt can lay them out on the right. -func (s *Server) handleHoldemChat(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleTableChat(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return @@ -152,7 +160,7 @@ func (s *Server) handleHoldemChat(w http.ResponseWriter, r *http.Request) { // stamped with the hand it was said during — the one question chat at a money // table ever really raises — and pushed to every open stream so it lands on the // rail in real time. -func (s *Server) handleHoldemSay(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleTableSay(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return diff --git a/internal/web/games_uno.go b/internal/web/games_uno.go index 3323a00..25b19dc 100644 --- a/internal/web/games_uno.go +++ b/internal/web/games_uno.go @@ -5,58 +5,59 @@ import ( "errors" "log/slog" "net/http" + "time" "pete/internal/games/blackjack" "pete/internal/games/uno" "pete/internal/storage" ) -// UNO, played for chips against bots. +// UNO, played for chips at a shared table. // -// 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. +// Like hold'em, this is a session: you sit down with a stack, ante into a pot each +// hand, and leave with what is in front of you. Chips cross the border twice — +// sit-down and get-up — and every ante and pot in between moves inside the state +// blob. Solo play is just a table nobody else has joined. // -// 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. +// The seam is the same as hold'em: one request plays a human's move plus every +// bot turn it hands off to, returned as a script the felt animates. What a viewer +// is allowed to see is their own hand, the card and colour in play, the pot, and +// how many cards each other seat holds — never the deck, another seat's hand, or +// the face of a card a bot drew. The engine emits every seat's hand (a shared +// stream cannot know who is watching); the redaction that keeps a hand private is +// here, and a missed case fans it to every subscriber. -// 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. +// unoCardView is one card, ready to draw: colour and face as words, not the +// engine's integers. 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 + Color string `json:"color"` + Value string `json:"value"` + Wild bool `json:"wild"` } func viewUnoCard(c uno.Card) unoCardView { - return unoCardView{ - Color: c.Color.String(), - Value: c.Value.String(), - Wild: c.IsWild(), - } + 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. +// unoSeatView is one seat at the table: a name, a card count, and a stack. A +// seat'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"` + Bot bool `json:"bot"` You bool `json:"you"` + Cards int `json:"cards"` + Stack int64 `json:"stack"` + Ante int64 `json:"ante,omitempty"` 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 + Called bool `json:"called"` // …and said so. Uno true and this false is a seat you can catch + Out bool `json:"out"` // not in this hand — mercy-killed, or sitting one out } -// unoView is a game as its player may see it. +// unoView is a table as one seat may see it. type unoView struct { - Tier uno.Tier `json:"tier"` - Seats []unoSeatView `json:"seats"` + Tier uno.Tier `json:"tier"` + YourSeat int `json:"your_seat"` + Seats []unoSeatView `json:"seats"` Hand []unoCardView `json:"hand"` // yours, and only yours Playable []int `json:"playable"` // which of them can legally go down @@ -64,138 +65,138 @@ type unoView struct { 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"` + UnoAt []int `json:"uno_at"` // your cards that would leave you on one + Catchable []int `json:"catchable"` // seats sitting on one card they never called - Turn int `json:"turn"` - Dir int `json:"dir"` + Turn int `json:"turn"` + Dir int `json:"dir"` + Dealer int `json:"dealer"` + HandNo int `json:"hand_no"` - // 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"` + Pending int `json:"pending"` // No Mercy: the bill a stack has run up - 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"` + Pot int64 `json:"pot"` // the antes riding on this hand + Ante int64 `json:"ante"` // what each seat puts in + Stack int64 `json:"stack"` // what's in front of you + BoughtIn int64 `json:"bought_in"` // your own buy-in, from the border ledger + Phase string `json:"phase"` + + // The last hand's verdict, so the felt can land it between hands. Winner int `json:"winner"` - Payout int64 `json:"payout,omitempty"` + LastPot int64 `json:"last_pot,omitempty"` Rake int64 `json:"rake,omitempty"` - Net int64 `json:"net"` + Outcome string `json:"outcome,omitempty"` } -func viewUno(g uno.State) unoView { +// viewUno renders the table as one seat may see it. viewer is which seat is +// looking — their hand is the only one it will ever put in the payload. +// +// This is the security boundary. The same view fans to every subscriber's stream, +// so a seat that renders anyone else's cards fans them to the whole table. +// TestUnoViewNeverLeaksAnotherSeatsCards renders every seat's view and greps for +// cards that are not theirs. +func viewUno(g uno.State, viewer int) 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(), + Tier: g.Tier, + YourSeat: viewer, + Top: viewUnoCard(g.Top()), + Color: g.Color.String(), + Deck: g.Left(), + Turn: g.Turn, + Dir: g.Dir, + Dealer: g.Dealer, + HandNo: g.HandNo, + Pending: g.Pending, + Pot: g.Pot, + Ante: g.Tier.Ante, + BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake + Phase: string(g.Phase), + Winner: g.Winner, + LastPot: g.LastPot, + Rake: g.Rake, + Outcome: string(g.Outcome), } - // 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() { + if viewer >= 0 && viewer < len(g.Seats) { + v.Stack = g.Seats[viewer].Stack + } + counts := g.Counts() + for i := range g.Seats { + p := g.Seats[i] live := g.Live(i) seat := unoSeatView{ - Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live, + Name: p.Name, + Bot: p.Bot, + You: i == viewer, + Cards: counts[i], + Stack: p.Stack, + Ante: p.Ante, + Uno: live && counts[i] == 1, 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] + Out: !live, } v.Seats = append(v.Seats, seat) - if live && n == 0 { - v.Winner = i + } + + // The wall. Only the viewer's own hand crosses the wire. + if viewer >= 0 && viewer < len(g.Hands) { + for _, c := range g.Hands[viewer] { + v.Hand = append(v.Hand, viewUnoCard(c)) } } - // 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 + if v.Hand == nil { + v.Hand = []unoCardView{} } - for _, c := range g.Hands[uno.You] { - v.Hand = append(v.Hand, viewUnoCard(c)) - } - v.Playable = g.Playable() + // Empty arrays, never null: the felt indexes into these. + v.Playable = g.Playable(viewer) 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() + v.UnoAt = g.UnoAt(viewer) if v.UnoAt == nil { v.UnoAt = []int{} } - v.Catchable = g.Catchable() + v.Catchable = g.Catchable(viewer) 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. +// unoEventView is one beat of the script the felt plays back. The engine attaches +// every seat's hand and drawn cards; this is the wall where the ones the viewer +// isn't entitled to are stripped. 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"` + 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"` + By int `json:"by"` + Text string `json:"text,omitempty"` + Hand []unoCardView `json:"hand,omitempty"` } -func viewUnoEvents(evs []uno.Event) []unoEventView { +func viewUnoEvents(evs []uno.Event, viewer int) []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 { + // A hand rides an event only if it is the viewer's own. The engine stamps every + // seat's hand; this strips the rest. + if e.Seat == viewer && e.Hand != nil { v.Hand = make([]unoCardView, 0, len(e.Hand)) for _, c := range e.Hand { v.Hand = append(v.Hand, viewUnoCard(c)) } } + // A card rides an event only if it is a card played face up, or one the viewer + // drew. A bot's (or another human's) drawn card never carries a face. 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 { + if e.Seat == viewer { c := viewUnoCard(*e.Card) v.Card = &c } @@ -209,52 +210,227 @@ func viewUnoEvents(evs []uno.Event) []unoEventView { 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) { +// ---- sitting down ---------------------------------------------------------- + +// unoSeatRows mirrors the engine's seats into the storage rows that shadow them, +// index for index. A human's staked is the buy-in that crossed the border; a +// bot's is zero. +func unoSeatRows(g uno.State, human string, buyIn int64) []storage.Seat { + rows := make([]storage.Seat, len(g.Seats)) + for i := range g.Seats { + p := g.Seats[i] + row := storage.Seat{Seat: i, Name: p.Name} + if !p.Bot { + row.MatrixUser = human + row.Staked = buyIn + } + rows[i] = row + } + return rows +} + +// handleUnoSit seats a player at a fresh table of their own or at an open chair on +// somebody else's. +func (s *Server) handleUnoSit(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"` + Tier string `json:"tier"` + BuyIn int64 `json:"buyin"` + Table string `json:"table"` + Seat *int `json:"seat"` } - if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 { - writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"}) + if err := decodeJSON(r, &req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) return } - tier, err := uno.TierBySlug(req.Tier) + if req.Table != "" { + s.joinUno(w, r, user, req.Table, req.Seat, req.BuyIn) + return + } + s.openUno(w, r, user, req.Tier, req.BuyIn) +} + +// openUno opens a fresh table with the player in seat zero and bots in the rest — +// the old solo flow, now a real shared table with no other humans on it yet. +func (s *Server) openUno(w http.ResponseWriter, r *http.Request, user, tierSlug string, buyIn int64) { + tier, err := uno.TierBySlug(tierSlug) 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) + if buyIn < tier.MinBuy || buyIn > tier.MaxBuy { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"}) return } + name := s.displayName(r, user) seed1, seed2 := newSeeds() - g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2) + g, _, err := uno.New(tier, uno.TableSeats(tier, name, tier.Bots, buyIn), 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) + slog.Error("games: uno open", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } - s.persistUno(w, user, g, evs, seed1, seed2, true) + blob, err := json.Marshal(g) + if err != nil { + slog.Error("games: marshal new uno", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + id, err := storage.NewTableID() + if err != nil { + slog.Error("games: mint table id", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + t := storage.Table{ + ID: id, Game: gameUno, Tier: tier.Slug, State: blob, + Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo), + } + err = storage.OpenSoloTable(t, unoSeatRows(g, user, buyIn), buyIn) + switch { + case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount): + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"}) + return + case errors.Is(err, storage.ErrHandInProgress): + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"}) + return + case err != nil: + slog.Error("games: open solo uno table", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.writeUnoTable(w, user, nil) } -// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew. -// The bots' turns come back with it. +// pickOpenUnoSeat chooses a chair to join: the one asked for if it is a bot's, +// otherwise the first bot seat. Returns -1 if there is nowhere to sit. +func pickOpenUnoSeat(g uno.State, want *int) int { + if want != nil { + i := *want + if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot { + return i + } + return -1 + } + for i := range g.Seats { + if g.Seats[i].Bot { + return i + } + } + return -1 +} + +// joinUno sits a player at an open chair on an existing table, one transaction +// under the table lock, so two people racing for the last seat cannot both win it. +func (s *Server) joinUno(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) { + name := s.displayName(r, user) + var respErr error + err := s.tableLocks.withTable(tableID, func() error { + t, _, err := storage.LoadTable(tableID) + if errors.Is(err, storage.ErrNoSuchTable) { + respErr = storage.ErrNoSuchTable + return nil + } + if err != nil { + return err + } + if t.Game != gameUno { + respErr = uno.ErrUnknownMove + return nil + } + var g uno.State + if err := json.Unmarshal(t.State, &g); err != nil { + return err + } + if unoPlaying(g) { + respErr = uno.ErrHandLive // you join between hands + return nil + } + seat := pickOpenUnoSeat(g, wantSeat) + if seat < 0 { + respErr = uno.ErrTableFull + return nil + } + if err := g.Occupy(seat, name, buyIn); err != nil { + respErr = err + return nil + } + blob, err := json.Marshal(g) + if err != nil { + return err + } + t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo) + err = storage.SitDown(storage.Sit{ + Table: t, + Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn}, + BuyIn: buyIn, + }) + switch { + case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount): + respErr = storage.ErrInsufficientChips + return nil + case errors.Is(err, storage.ErrHandInProgress): + respErr = storage.ErrHandInProgress + return nil + case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable): + respErr = storage.ErrSeatTaken + return nil + case err != nil: + return err + } + s.publishTable(tableID) + return nil + }) + if err != nil { + slog.Error("games: join uno", "user", user, "table", tableID, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if respErr != nil { + writeJSONStatus(w, unoJoinStatus(respErr), map[string]string{"error": unoJoinMessage(respErr)}) + return + } + s.writeUnoTable(w, user, nil) +} + +func unoJoinStatus(err error) int { + switch { + case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, uno.ErrTableFull), + errors.Is(err, storage.ErrSeatTaken), errors.Is(err, uno.ErrHandLive): + return http.StatusConflict + default: + return http.StatusBadRequest + } +} + +func unoJoinMessage(err error) string { + switch { + case errors.Is(err, storage.ErrNoSuchTable): + return "that table has closed" + case errors.Is(err, uno.ErrTableFull), errors.Is(err, storage.ErrSeatTaken): + return "that seat is taken" + case errors.Is(err, uno.ErrHandLive): + return "a hand is in play — sit down when it's over" + case errors.Is(err, uno.ErrBadBuyIn): + return "that isn't a legal buy-in for this table" + case errors.Is(err, storage.ErrInsufficientChips): + return "not enough chips to sit down" + case errors.Is(err, storage.ErrHandInProgress): + return "finish the game you're in first" + default: + return "you can't sit there" + } +} + +// ---- playing a hand -------------------------------------------------------- + +// handleUnoMove plays one move at the player's table: a hand move, or dealing the +// next hand. Leaving is its own endpoint. func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { @@ -265,80 +441,237 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad json", http.StatusBadRequest) return } + if move.Kind == uno.MoveLeave { + s.leaveUno(w, user) + return + } - live, err := storage.LoadLiveHand(user) + tableID, seat, err := storage.PlayerSeat(user) if errors.Is(err, storage.ErrNoLiveHand) { - writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"}) + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"}) 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) + slog.Error("games: uno move seat", "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" + var respErr error + var respEvents []uno.Event + err = s.tableLocks.withTable(tableID, func() error { + t, seats, err := storage.LoadTable(tableID) + if errors.Is(err, storage.ErrNoSuchTable) { + respErr = storage.ErrNoSuchTable + return nil } - writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg}) + if err != nil { + return err + } + var g uno.State + if err := json.Unmarshal(t.State, &g); err != nil { + return err + } + + next, evs, aerr := uno.ApplyMove(g, seat, move) + if aerr != nil { + respErr = aerr + return nil + } + + // A solo session that just ended (the one human got up or busted at the deal) + // is not a table any more: cash the seat out and close the felt. Only a solo + // table reaches PhaseDone; a shared table plays on. + if next.Phase == uno.PhaseDone { + if err := s.settleUnoLeave(t, next, seat, user); err != nil { + if errors.Is(err, storage.ErrStaleTable) { + respErr = storage.ErrStaleTable + return nil + } + return err + } + respEvents = evs + s.publishTable(tableID) + return nil + } + + st, err := unoStep(next, evs, seats) + if err != nil { + return err + } + acting := seats[seat] + acting.Away = false + acting.LastSeen = time.Now().Unix() + + t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline + if err := storage.CommitTable(storage.TableCommit{ + Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit, + }); err != nil { + if errors.Is(err, storage.ErrStaleTable) { + respErr = storage.ErrStaleTable + return nil + } + return err + } + respEvents = evs + s.publishTable(tableID) + return nil + }) + if err != nil { + slog.Error("games: uno move", "user", user, "table", tableID, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) return } - s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false) + if respErr != nil { + writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)}) + return + } + s.writeUnoTable(w, user, respEvents) } -// 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 +func unoMoveStatus(err error) int { + switch { + case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable): + return http.StatusConflict + default: + return http.StatusBadRequest } - 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, - }) +} + +func unoMoveMessage(err error) string { + switch { + case errors.Is(err, storage.ErrStaleTable): + return "the table moved on — take another look" + case errors.Is(err, storage.ErrNoSuchTable): + return "that table has closed" + case errors.Is(err, uno.ErrHandLive): + return "finish the hand first" + case errors.Is(err, uno.ErrNoHand): + return "there's no hand in play — deal one" + case errors.Is(err, uno.ErrNotYourTurn): + return "it isn't your turn" + case errors.Is(err, uno.ErrCantPlay): + return "that card doesn't go on this one" + case errors.Is(err, uno.ErrNeedColor): + return "pick a colour for the wild" + case errors.Is(err, uno.ErrMustPlayNow): + return "play the card you drew, or pass" + case errors.Is(err, uno.ErrCantPass): + return "draw first, then you can pass" + case errors.Is(err, uno.ErrMustStack): + return "answer the stack with a draw card, or take it" + case errors.Is(err, uno.ErrNoStack): + return "there's nothing pointed at you to take" + case errors.Is(err, uno.ErrNoCatch): + return "there's nobody in that seat to catch" + default: + return "that move isn't legal here" + } +} + +// ---- getting up ------------------------------------------------------------ + +// handleUnoLeave is the get-up endpoint. Leaving is its own route because it is a +// storage operation, not an engine move — the chips cross the border and the felt +// may close. +func (s *Server) handleUnoLeave(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) 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 + s.leaveUno(w, user) +} + +// leaveUno gets a player up from their table, turning what is in front of them +// back into chips. It refuses mid-hand and closes the felt behind the last human. +func (s *Server) leaveUno(w http.ResponseWriter, user string) { + tableID, seat, err := storage.PlayerSeat(user) + if errors.Is(err, storage.ErrNoLiveHand) { + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"}) + return + } + if err != nil { + slog.Error("games: uno leave seat", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + var respErr error + err = s.tableLocks.withTable(tableID, func() error { + t, _, err := storage.LoadTable(tableID) + if errors.Is(err, storage.ErrNoSuchTable) { + respErr = storage.ErrNoSuchTable + return nil + } + if err != nil { + return err + } + var g uno.State + if err := json.Unmarshal(t.State, &g); err != nil { + return err + } + if unoPlaying(g) { + respErr = uno.ErrHandLive + return nil + } + if err := s.settleUnoLeave(t, g, seat, user); err != nil { + if errors.Is(err, storage.ErrStaleTable) { + respErr = storage.ErrStaleTable + return nil + } + return err + } + s.publishTable(tableID) + return nil + }) + if err != nil { + slog.Error("games: uno leave", "user", user, "table", tableID, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if respErr != nil { + writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)}) + return + } + s.writeUnoTable(w, user, nil) +} + +// settleUnoLeave vacates a seat, credits the stack home, and closes the table if +// nobody human is left — all inside the caller's lock. +func (s *Server) settleUnoLeave(t storage.Table, g uno.State, seat int, user string) error { + home, err := g.Vacate(seat) + if err != nil { + return err + } + blob, err := json.Marshal(g) + if err != nil { + return err + } + t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0 + if err := storage.LeaveTable(storage.Leave{ + Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home, + }); err != nil { + return err + } + return storage.CloseTable(t.ID) +} + +// ---- the response ---------------------------------------------------------- + +// writeUnoTable answers with the whole page state — the money and the table as the +// player's own seat may see it — plus, when a move produced one, the redacted event +// script for that seat to animate. +func (s *Server) writeUnoTable(w http.ResponseWriter, user string, evs []uno.Event) { + v, err := s.table(user) + if err != nil { + slog.Error("games: uno table", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if len(evs) > 0 { + if _, seat, serr := storage.PlayerSeat(user); serr == nil { + v.UnoEvents = viewUnoEvents(evs, seat) + } } - v.UnoEvents = viewUnoEvents(evs) writeJSON(w, v) } diff --git a/internal/web/games_uno_table.go b/internal/web/games_uno_table.go new file mode 100644 index 0000000..4226fd0 --- /dev/null +++ b/internal/web/games_uno_table.go @@ -0,0 +1,187 @@ +package web + +import ( + "encoding/json" + "time" + + "pete/internal/games/uno" + "pete/internal/storage" +) + +// UNO as a shared table: the seam the runtime drives it through, and the two +// facts about a hand only the engine can tell the runtime — when the clock must +// next act, and what a finished hand owes the audit trail. +// +// Everything about *playing* UNO is still in the engine. This file is the +// translation layer between a uno.State and the game-agnostic table runtime. + +// unoTable is the tableGame for UNO. It holds no state — the table's state is the +// blob in game_tables — so a single value serves every felt in the room. +type unoTable struct{} + +func (unoTable) name() string { return gameUno } + +// timeout plays a passive move for the human whose clock ran out and marks them +// away, so the runtime auto-acts them on sight after this rather than waiting a +// full clock every orbit. The move is the gentlest one that still advances the +// turn: give in to a stack, pass a drawn card (or play it where the rules force +// it), otherwise play a legal card if there is one, or draw. +// +// If, on decode, the seat to act is not a waiting human, the scan raced a real +// move that had not yet bumped the version: errNotDue, and the clock steps aside. +func (unoTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) { + var g uno.State + if err := json.Unmarshal(state, &g); err != nil { + return step{}, nil, err + } + if !unoPlaying(g) { + return step{}, seats, errNotDue + } + seat := g.Turn + if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot { + return step{}, seats, errNotDue + } + + move := unoIdleMove(g, seat) + next, evs, err := uno.ApplyMove(g, seat, move) + if err != nil { + return step{}, nil, err + } + + changed := markAway(seats, seat) + st, err := unoStep(next, evs, seats) + return st, changed, err +} + +// unoIdleMove is the move the clock makes for a walked-away seat: the most passive +// legal one that still hands the turn on. +func unoIdleMove(g uno.State, seat int) uno.Move { + switch g.Phase { + case uno.PhaseStack: + return uno.Move{Kind: uno.MoveTake} + case uno.PhaseDrawn: + if g.Tier.NoMercy { + // No Mercy makes you play the card you drew; it is the last in the hand. + return uno.Move{Kind: uno.MovePlay, Index: len(g.Hands[seat]) - 1, Color: uno.Red} + } + return uno.Move{Kind: uno.MovePass} + default: + if p := g.Playable(seat); len(p) > 0 { + return uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red} + } + return uno.Move{Kind: uno.MoveDraw} + } +} + +// stacks reports the chips in front of each seat, index-aligned with the table's +// seat rows, so the abandoned-table reaper can cash out a walked-away human. +func (unoTable) stacks(state []byte) ([]int64, error) { + var g uno.State + if err := json.Unmarshal(state, &g); err != nil { + return nil, err + } + out := make([]int64, len(g.Seats)) + for i := range g.Seats { + out[i] = g.Seats[i].Stack + } + return out, nil +} + +// unoStep packages a played-out state for the runtime: the blob to persist, the +// deadline the clock must honour next, and the audit of any hand that just ended. +func unoStep(next uno.State, evs []uno.Event, seats []storage.Seat) (step, error) { + blob, err := json.Marshal(next) + if err != nil { + return step{}, err + } + st := step{ + State: blob, + Phase: string(next.Phase), + HandNo: int64(next.HandNo), + Deadline: unoDeadline(next, seats), + Audit: unoAudit(next, evs, seats), + } + return st, nil +} + +// unoPlaying reports whether a hand is in progress — the only phase a turn clock +// has anything to do in. +func unoPlaying(g uno.State) bool { + switch g.Phase { + case uno.PhasePlay, uno.PhaseDrawn, uno.PhaseStack: + return true + } + return false +} + +// unoDeadline is when the clock must next act, or 0 for never. A clock is only set +// on a *present* human whose turn it is: a bot resolves inside the move, an away +// human is auto-acted on sight, and between hands there is no per-seat clock — +// dealing the next hand is a seated player's call, and an abandoned table is the +// reaper's job. +func unoDeadline(g uno.State, seats []storage.Seat) int64 { + if !unoPlaying(g) { + return 0 + } + seat := g.Turn + if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot { + return 0 + } + if seatAway(seats, seat) { + return 0 + } + return time.Now().Unix() + turnSeconds +} + +// unoAudit is the per-hand record of a finished hand — one row per human who was +// in it. Empty until a hand actually ends, which is exactly when a settle beat is +// emitted. +// +// The rake rides on the winner's row alone, and every other seat carries zero, so +// game_hands.rake (which HouseTake sums) records a pot's rake once and once only. +// A seat's ante is its bet; a refunded tie pays each seat its ante straight back. +func unoAudit(g uno.State, evs []uno.Event, seats []storage.Seat) []storage.Hand { + if !unoHandEnded(evs) { + return nil + } + var audit []storage.Hand + for i := range g.Seats { + p := g.Seats[i] + if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" { + continue + } + if p.Ante == 0 { + continue // sat this hand out — nothing to record + } + outcome, payout, rake := "lost", int64(0), int64(0) + switch { + case i == g.Winner: + outcome, payout, rake = "won", p.Won, g.Rake + case g.Outcome == uno.OutcomeTie: + outcome, payout = "push", p.Ante // the ante came straight back + } + audit = append(audit, storage.Hand{ + MatrixUser: seats[i].MatrixUser, + Game: gameUno, + Bet: p.Ante, + Payout: payout, + Rake: rake, + Outcome: outcome, + Seed1: g.Seed1, + Seed2: g.Seed2, + }) + } + return audit +} + +// unoHandEnded reports whether a hand finished in this batch of events. A settle +// beat is emitted exactly once, when a hand ends, so it is the clean signal that +// the seats' Ante/Won are this hand's final numbers. +func unoHandEnded(evs []uno.Event) bool { + for _, e := range evs { + if e.Kind == uno.EvSettle { + return true + } + } + return false +} diff --git a/internal/web/games_uno_test.go b/internal/web/games_uno_test.go index 2219233..207e01d 100644 --- a/internal/web/games_uno_test.go +++ b/internal/web/games_uno_test.go @@ -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 -} diff --git a/internal/web/server.go b/internal/web/server.go index 1677f2f..d539f6a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -144,7 +144,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo live = append(live, ch) } - s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}}} + s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}} // Optional OIDC sign-in (Authentik). Discovery is a network call; if the // provider is unreachable at boot we log and serve anonymously rather than diff --git a/internal/web/templates/uno.html b/internal/web/templates/uno.html index 53632fd..4657fce 100644 --- a/internal/web/templates/uno.html +++ b/internal/web/templates/uno.html @@ -174,11 +174,11 @@ class="pete-tier rounded-2xl border-2 p-3 text-left transition">
{{.Blurb}}
- {{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each + {{.Bots}} bot{{if gt .Bots 1}}s{{end}} · winner takes the pot
{{end}} @@ -192,7 +192,7 @@ class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition">{{.Blurb}}