From 6e20883e5d71a77a64bb3a561842b2bf9ae8752f Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:50:52 -0700 Subject: [PATCH] games: the table that couldn't end, and the lock that let go too early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code review of the uno table found the stuck guard had never once fired. It counted how many bots had passed in a row and wanted more of them than there are seats — but the bot loop hands the turn back the moment it comes round to you, so the count could never get there, and your own empty-handed pass was never in it. A dead table just passed the turn round forever. That is not an ugly ending, it's a game you cannot finish, and a game you cannot finish is chips you cannot cash out. So it asks the real question now: is there anything to draw, and is anyone holding a card that goes. And the table let go of itself too early. busy came off when the request landed, not when the script it came back with had finished playing — so for the seconds a bot lap takes, you could click a card at a board the server had already moved past. It comes off at the end now, like the other tables. Also: left: 0 was being dropped on its way out the door, which is the one number that matters (the seat that just went out), the deck counter didn't come back after a reshuffle, and hoisting fly() into flyNode() had quietly flattened the chip arc on every other table in the room. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ --- internal/games/uno/uno.go | 65 +++++++++++++++++----------- internal/games/uno/uno_test.go | 51 ++++++++++++++++++++++ internal/web/games_uno.go | 2 +- internal/web/static/js/casino-fx.js | 7 ++- internal/web/static/js/uno.js | 67 ++++++++++++++++++----------- 5 files changed, 141 insertions(+), 51 deletions(-) diff --git a/internal/games/uno/uno.go b/internal/games/uno/uno.go index be464b5..b98f5c6 100644 --- a/internal/games/uno/uno.go +++ b/internal/games/uno/uno.go @@ -242,7 +242,7 @@ type Event struct { Card *Card `json:"card,omitempty"` // the card played, or the one *you* drew 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,omitempty"` // cards left in that seat's hand afterwards + Left int `json:"left"` // cards left in that seat's hand afterwards Text string `json:"text,omitempty"` } @@ -379,6 +379,12 @@ func ApplyMove(s State, m Move) (State, []Event, error) { // 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. 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) + } return next, evs, nil } @@ -438,39 +444,27 @@ func (s *State) playerPasses() ([]Event, error) { } // runBots plays every bot turn between you and your next one. It stops the -// moment the game is over or the turn comes back round. +// 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. func (s *State) runBots(evs *[]Event, rng *rand.Rand) { - // A bot that can neither play nor draw passes, and if every seat does that in - // a row the game is stuck: the deck is spent and the discard has nothing under - // its top card to become a new one. Rare, but a game that can't end is worse - // than one that ends badly, so it ends — see stuck(). - passes := 0 - for s.Phase != PhaseDone && s.Turn != You { - if s.botTurn(s.Turn, evs, rng) { - passes++ - if passes > len(s.Hands) { - s.stuck(evs) - return - } - continue - } - passes = 0 + for s.Phase != PhaseDone && s.Turn != You && !s.stalled() { + s.botTurn(s.Turn, evs, rng) } } -// botTurn plays one bot's turn. It reports whether the bot passed with nothing. -func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) (passed bool) { +// botTurn plays one bot's turn. +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 one, and play it if it happens to go. drawn := s.deal(seat, 1, false, evs, rng) - if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) { - card, idx = drawn[0], len(s.Hands[seat])-1 - } else { + if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) { *evs = append(*evs, Event{Kind: EvPass, Seat: seat}) s.advance(1) - return len(drawn) == 0 // a bot that couldn't even draw is a stuck table + return } + card, idx = drawn[0], len(s.Hands[seat])-1 } hand := s.Hands[seat] @@ -482,7 +476,30 @@ func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) (passed bool) { } s.discard(seat, card, color, evs) s.after(seat, card, evs, rng) - return false +} + +// 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 len(s.Deck) > 0 || len(s.Discard) > 1 { + return false // there is a card to draw, or a discard to make one out of + } + for _, hand := range s.Hands { + for _, c := range hand { + if c.CanPlayOn(s.top(), s.Color) { + return false + } + } + } + return true } // discard puts a card on the pile and names the colour now in play. diff --git a/internal/games/uno/uno_test.go b/internal/games/uno/uno_test.go index a12c33f..96c626f 100644 --- a/internal/games/uno/uno_test.go +++ b/internal/games/uno/uno_test.go @@ -365,6 +365,57 @@ func TestPassOnlyAfterADraw(t *testing.T) { } } +// 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. +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. +func TestDeadTableEnds(t *testing.T) { + s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) + + next, evs, err := ApplyMove(s, 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.Outcome != OutcomeStuck { + t.Errorf("a tie on the shortest hand is nobody going out: %q", next.Outcome) + } + if next.Payout != 0 { + t.Errorf("a stuck game pays nothing, not %d", next.Payout) + } + if !hasKind(evs, EvSettle) { + t.Errorf("the table has to be told it's over: %+v", evs) + } +} + +// And the shortest hand takes it, which is the one way a stuck table still pays. +func TestDeadTablePaysTheShortestHand(t *testing.T) { + s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}}) + + next, _, err := ApplyMove(s, 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.Payout != s.Pays() { + t.Errorf("payout %d, quoted %d — the felt has to honour what it advertised", next.Payout, s.Pays()) + } +} + 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. diff --git a/internal/web/games_uno.go b/internal/web/games_uno.go index fb6d030..2bd4c59 100644 --- a/internal/web/games_uno.go +++ b/internal/web/games_uno.go @@ -124,7 +124,7 @@ type unoEventView struct { Card *unoCardView `json:"card,omitempty"` Color string `json:"color,omitempty"` N int `json:"n,omitempty"` - Left int `json:"left,omitempty"` + Left int `json:"left"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw Text string `json:"text,omitempty"` } diff --git a/internal/web/static/js/casino-fx.js b/internal/web/static/js/casino-fx.js index 983ef0c..6c978d5 100644 --- a/internal/web/static/js/casino-fx.js +++ b/internal/web/static/js/casino-fx.js @@ -101,11 +101,16 @@ var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin; var s0 = opts.fromScale == null ? 0.85 : opts.fromScale; var s1 = opts.toScale == null ? 1 : opts.toScale; + // Bigger at the top of the arc than at either end — that swell is what sells + // the thing as coming towards you. Taken off the larger end, so a throw that + // starts small and lands full size still peaks above where it lands, rather + // than averaging itself back down to nothing. + var sMid = Math.max(s0, s1) * 1.12; var anim = node.animate( [ { transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 }, - { transform: t(midX, midY, (s0 + s1) / 2 * 1.12, spin * 0.6), opacity: 1, offset: 0.5 }, + { transform: t(midX, midY, sMid, spin * 0.6), opacity: 1, offset: 0.5 }, { transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 }, ], { duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" } diff --git a/internal/web/static/js/uno.js b/internal/web/static/js/uno.js index 2e5e6eb..165b028 100644 --- a/internal/web/static/js/uno.js +++ b/internal/web/static/js/uno.js @@ -286,23 +286,34 @@ if (v.outcome === "won") FX.burst(verdictEl, { count: 34 }); } + // controls is the one place that decides what you can click: whose turn the + // server says it is, and whether a move of yours is still in flight. Both + // halves matter, and they used to be spread across three functions that each + // knew half of it — which is how the table came to unlock itself in the middle + // of a bot's turn. + function controls() { + var live = !!game && game.phase !== "done"; + var yours = live && game.turn === 0; + var drawn = live && game.phase === "drawn"; + handEl.querySelectorAll(".pete-uno-card").forEach(function (b) { + b.disabled = busy || !yours || b.dataset.on !== "1"; + }); + if (drawBtn) drawBtn.disabled = busy || !yours || drawn; + if (passBtn) passBtn.disabled = busy || !yours; + if (deckEl) deckEl.disabled = busy || !yours || drawn; + } + function setPhase(v) { game = v; var live = !!v && v.phase !== "done"; + var drawn = live && v.phase === "drawn"; betting.classList.toggle("hidden", live); playing.classList.toggle("hidden", !live); hideWild(); - var yours = live && v.turn === 0; - if (drawBtn) { - drawBtn.disabled = !yours || v.phase === "drawn" || busy; - drawBtn.classList.toggle("hidden", !!(live && v.phase === "drawn")); - } - if (passBtn) { - passBtn.classList.toggle("hidden", !(live && v.phase === "drawn")); - passBtn.disabled = !yours || busy; - } - if (deckEl) deckEl.disabled = !yours || v.phase === "drawn" || busy; + if (drawBtn) drawBtn.classList.toggle("hidden", drawn); + if (passBtn) passBtn.classList.toggle("hidden", !drawn); + controls(); if (!v || !v.outcome) verdictEl.classList.add("hidden"); } @@ -453,6 +464,10 @@ return badge(e.seat, "UNO!", "uno"); case "reshuffle": + // The discard has just gone back under, so the counter has to as + // well: the draws that follow in this same script count down from it, + // and counting down from a deck that still reads empty gets nowhere. + deckCntEl.textContent = String(e.n); deckEl.classList.add("pete-uno-shuffle"); return wait(420).then(function () { deckEl.classList.remove("pete-uno-shuffle"); @@ -498,10 +513,12 @@ var payout = final.payout || 0; if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true }); - var back = payout - final.bet; + // Not `back`: that is the card back up there, and a number wearing its name + // is a landmine for whoever next wants a face-down card in here. + var profit = payout - final.bet; return spot - .pour(houseEl, back, { gap: 60 }) - .then(function () { return wait(back > 0 ? 380 : 200); }) + .pour(houseEl, profit, { gap: 60 }) + .then(function () { return wait(profit > 0 ? 380 : 200); }) .then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); }); } @@ -578,35 +595,35 @@ // ---- talking to the table ---------------------------------------------------- + // send takes the table away for the whole move — not just the request, but the + // script that comes back with it. A lap of this table is seconds of animation, + // and for every one of them the game on screen is a game the server has already + // moved past. Clicking a card in there would send a move against a board that + // no longer exists. So busy comes off at the end of the script, not the end of + // the request, which is what the other tables do too. function send(path, body, where) { if (busy) return; busy = true; say("", null, where); - lock(); + controls(); return window.PeteGames.post(path, body) .then(function (view) { - busy = false; return play(view, function () { window.PeteGames.apply(view); }); }) .catch(function (err) { - busy = false; say(err.message, "bad", where); return window.PeteGames.refresh().then(function (v) { if (v && v.uno) paint(v.uno); else { paint(null); spot.render(0); } }); + }) + .then(function () { + busy = false; + controls(); + showBet(); }); } - // lock takes the table away while a move is in flight, so a second click can't - // send a second move against a game the first one is about to change. - function lock() { - handEl.querySelectorAll(".pete-uno-card").forEach(function (b) { b.disabled = true; }); - if (drawBtn) drawBtn.disabled = true; - if (passBtn) passBtn.disabled = true; - if (deckEl) deckEl.disabled = true; - } - // ---- betting ------------------------------------------------------------------ function showBet() {