games: the table that couldn't end, and the lock that let go too early

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
This commit is contained in:
prosolis
2026-07-14 07:50:52 -07:00
parent d7e63d86a6
commit 6e20883e5d
5 changed files with 141 additions and 51 deletions

View File

@@ -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.