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

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

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.