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:
@@ -242,7 +242,7 @@ type Event struct {
|
|||||||
Card *Card `json:"card,omitempty"` // the card played, or the one *you* drew
|
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
|
Color Color `json:"color,omitempty"` // the colour now in play, on a wild
|
||||||
N int `json:"n,omitempty"` // how many cards were drawn
|
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"`
|
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
|
// 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.
|
// back as one script. This is the reason solo UNO needs no socket.
|
||||||
next.runBots(&evs, rng)
|
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
|
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
|
// 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) {
|
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
|
for s.Phase != PhaseDone && s.Turn != You && !s.stalled() {
|
||||||
// a row the game is stuck: the deck is spent and the discard has nothing under
|
s.botTurn(s.Turn, evs, rng)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// botTurn plays one bot's turn. It reports whether the bot passed with nothing.
|
// botTurn plays one bot's turn.
|
||||||
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) (passed bool) {
|
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)
|
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
// Nothing playable: draw one, and play it if it happens to go.
|
// Nothing playable: draw one, and play it if it happens to go.
|
||||||
drawn := s.deal(seat, 1, false, evs, rng)
|
drawn := s.deal(seat, 1, false, evs, rng)
|
||||||
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
|
if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) {
|
||||||
card, idx = drawn[0], len(s.Hands[seat])-1
|
|
||||||
} else {
|
|
||||||
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
||||||
s.advance(1)
|
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]
|
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.discard(seat, card, color, evs)
|
||||||
s.after(seat, card, evs, rng)
|
s.after(seat, card, evs, rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// discard puts a card on the pile and names the colour now in play.
|
// discard puts a card on the pile and names the colour now in play.
|
||||||
|
|||||||
@@ -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) {
|
func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
||||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
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.
|
// An empty deck, and a discard with something under the top card to become one.
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ type unoEventView struct {
|
|||||||
Card *unoCardView `json:"card,omitempty"`
|
Card *unoCardView `json:"card,omitempty"`
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
N int `json:"n,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"`
|
Text string `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,11 +101,16 @@
|
|||||||
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
|
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
|
||||||
var s0 = opts.fromScale == null ? 0.85 : opts.fromScale;
|
var s0 = opts.fromScale == null ? 0.85 : opts.fromScale;
|
||||||
var s1 = opts.toScale == null ? 1 : opts.toScale;
|
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(
|
var anim = node.animate(
|
||||||
[
|
[
|
||||||
{ transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
|
{ 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 },
|
{ 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" }
|
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
|
||||||
|
|||||||
@@ -286,23 +286,34 @@
|
|||||||
if (v.outcome === "won") FX.burst(verdictEl, { count: 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) {
|
function setPhase(v) {
|
||||||
game = v;
|
game = v;
|
||||||
var live = !!v && v.phase !== "done";
|
var live = !!v && v.phase !== "done";
|
||||||
|
var drawn = live && v.phase === "drawn";
|
||||||
betting.classList.toggle("hidden", live);
|
betting.classList.toggle("hidden", live);
|
||||||
playing.classList.toggle("hidden", !live);
|
playing.classList.toggle("hidden", !live);
|
||||||
hideWild();
|
hideWild();
|
||||||
|
|
||||||
var yours = live && v.turn === 0;
|
if (drawBtn) drawBtn.classList.toggle("hidden", drawn);
|
||||||
if (drawBtn) {
|
if (passBtn) passBtn.classList.toggle("hidden", !drawn);
|
||||||
drawBtn.disabled = !yours || v.phase === "drawn" || busy;
|
controls();
|
||||||
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 (!v || !v.outcome) verdictEl.classList.add("hidden");
|
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +464,10 @@
|
|||||||
return badge(e.seat, "UNO!", "uno");
|
return badge(e.seat, "UNO!", "uno");
|
||||||
|
|
||||||
case "reshuffle":
|
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");
|
deckEl.classList.add("pete-uno-shuffle");
|
||||||
return wait(420).then(function () {
|
return wait(420).then(function () {
|
||||||
deckEl.classList.remove("pete-uno-shuffle");
|
deckEl.classList.remove("pete-uno-shuffle");
|
||||||
@@ -498,10 +513,12 @@
|
|||||||
var payout = final.payout || 0;
|
var payout = final.payout || 0;
|
||||||
if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
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
|
return spot
|
||||||
.pour(houseEl, back, { gap: 60 })
|
.pour(houseEl, profit, { gap: 60 })
|
||||||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
.then(function () { return wait(profit > 0 ? 380 : 200); })
|
||||||
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,35 +595,35 @@
|
|||||||
|
|
||||||
// ---- talking to the table ----------------------------------------------------
|
// ---- 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) {
|
function send(path, body, where) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
say("", null, where);
|
say("", null, where);
|
||||||
lock();
|
controls();
|
||||||
return window.PeteGames.post(path, body)
|
return window.PeteGames.post(path, body)
|
||||||
.then(function (view) {
|
.then(function (view) {
|
||||||
busy = false;
|
|
||||||
return play(view, function () { window.PeteGames.apply(view); });
|
return play(view, function () { window.PeteGames.apply(view); });
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
busy = false;
|
|
||||||
say(err.message, "bad", where);
|
say(err.message, "bad", where);
|
||||||
return window.PeteGames.refresh().then(function (v) {
|
return window.PeteGames.refresh().then(function (v) {
|
||||||
if (v && v.uno) paint(v.uno);
|
if (v && v.uno) paint(v.uno);
|
||||||
else { paint(null); spot.render(0); }
|
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 ------------------------------------------------------------------
|
// ---- betting ------------------------------------------------------------------
|
||||||
|
|
||||||
function showBet() {
|
function showBet() {
|
||||||
|
|||||||
Reference in New Issue
Block a user