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

@@ -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"`
}

View File

@@ -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" }

View File

@@ -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() {