games: the word you owe the table, and the hand you were already holding

Three things, and the first one was a bug.

Your own hand didn't move until the lap ended. bump() keeps the bots'
fans honest and has always refused seat zero, and nothing else touched
yours — so a +4 landing on you at the top of a lap put four backs into
your hand and then nothing, and the cards themselves turned up seconds
later when the script finished and paint() finally ran. You spent the
whole lap looking at a hand you no longer held. The engine now stamps
your hand onto every event that changes it (Event.Hand, seat zero only,
which is the one hand the browser is already entitled to see) and the
table redraws as the cards land. Measured in the running app: 2 -> 3
cards at 414ms into a 1791ms lap.

You couldn't call UNO, and not because the button was missing: going
down to one card *was* the call. discard() fired the uno event by
itself, which made it a thing that happened to you rather than a thing
you did, and a rule nobody can fail is not a rule. So now you say it or
you don't (Move.Uno), and if you don't, every bot still in the game gets
one look at you before any of them plays — because a bot that has moved
on is a bot that has stopped watching your hand. It runs the other way
too, and that half is the fun one: a bot forgets often enough to be
worth watching for, and when it does it says *nothing*. No event, no
badge, no tell on the felt except the count beside its fan reading
"1 card". Catch it and it takes two; call a seat that had nothing to
hide and you take two yourself, which is what stops the catch button
from being a thing you simply mash.

Which cards owe the call is the engine's answer, not a count of your
hand: No Mercy's "discard all" takes every card of its colour with it,
so a six-card hand can land on one, and a browser subtracting one from
six walks you into a catch it never warned you about.

And the room was silent. Every sound in here is *made* — an oscillator,
a burst of filtered noise, an envelope — the same bargain the weather
engine takes with its clouds. A card is a slap of noise through a
bandpass, a chip is two detuned sines with a knock on the front, a win
is four notes going up. No asset files, no round trips, and a sound can
be pitched and detuned per call instead of being the same wav three
hundred times. Hooked into the FX layer rather than into the games, so
every table that throws a chip or turns a card got it at once.

The multiples moved, and the test that exists to catch that caught it.
The naive strategy now calls UNO, because calling is a button and not a
strategy — what these tiers price is bad card play, not a player who
ignores the felt shouting at them — and on that footing the normal
tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full
House did not: it was paying a *negative* house edge, which is the house
paying you to sit down. Re-priced 3.8 -> 3.5.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 13:15:11 -07:00
parent a4666866a8
commit 39ed293f4f
22 changed files with 1450 additions and 59 deletions

View File

@@ -39,6 +39,9 @@
var tableEl = root.querySelector("[data-table-name]");
var wildEl = root.querySelector("[data-wild]");
var gateEl = root.querySelector("[data-unogate]");
var callBtn = root.querySelector("[data-uno-call]");
var timerEl = root.querySelector("[data-uno-timer]");
// Not [data-pending]: that is the chip bar's, it lives inside this root, and it
// comes first in the document. See the note in uno.html.
var billEl = root.querySelector("[data-bill]");
@@ -72,6 +75,14 @@
var played = -1; // the card you just played, so the script lifts that one out
// of the hand and not merely the first one that lit up
// The UNO call. CALL_MS is how long you get to say it once the card is on its
// way down — long enough that nobody playing the game misses it, short enough
// that it is still a thing you have to *do*. The engine has the other half of
// this rule (see call.go); this is only the window.
var CALL_MS = 2600;
var gate = null; // the play waiting on a call: {index, color}
var gateTimer = 0;
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
@@ -207,6 +218,9 @@
function renderSeats(v) {
seatsEl.innerHTML = "";
if (!v) return;
var catchable = {};
(v.catchable || []).forEach(function (i) { catchable[i] = true; });
v.seats.forEach(function (s, i) {
if (s.you) return; // you are the hand at the bottom, not a seat up here
var el = document.createElement("div");
@@ -216,6 +230,24 @@
if (s.uno) el.dataset.uno = "1";
if (s.out) el.dataset.out = "1"; // No Mercy buried them; they are not coming back
// One card, and it never said so. This button is the only thing on the felt
// that gives it away — a bot that forgets to call is silent by definition,
// and the tell was always meant to be the count beside its fan. The button
// is the table doing you the courtesy of putting a target on it.
if (catchable[i]) {
var got = document.createElement("button");
got.type = "button";
got.className = "pete-uno-catch";
got.dataset.catch = String(i);
got.textContent = "Catch!";
got.setAttribute("aria-label", "Catch " + s.name + " — one card, never called");
got.addEventListener("click", function (e) {
e.stopPropagation();
catchSeat(i);
});
el.appendChild(got);
}
var fan = document.createElement("div");
fan.className = "pete-uno-fan";
var show = Math.min(s.cards, FAN);
@@ -252,23 +284,65 @@
return el ? el.querySelector(".pete-uno-fan") : deckEl;
}
function renderHand(v) {
handEl.innerHTML = "";
if (!v || !v.hand) return;
var playable = {};
(v.playable || []).forEach(function (i) { playable[i] = true; });
var yours = v.turn === 0 && v.phase !== "done";
// shownHand is the faces currently on screen. paintHand diffs against it so that
// only cards that are actually new fan in — without it, every redraw re-deals
// the whole hand in front of you, and a hand gets redrawn several times a lap.
var shownHand = [];
v.hand.forEach(function (c, i) {
function key(c) { return c.color + "/" + c.value; }
// paintHand draws your fan. `live` is whether the cards are yours to click,
// which mid-script they are not: the game on screen is one the server has
// already moved past, and a card clicked during the lap would be a move against
// a board that no longer exists.
function paintHand(cards, playable, live) {
cards = cards || [];
// What was already on the table stays put. Matched by face and consumed, so a
// hand holding two red fives doesn't count one of them twice.
var pool = shownHand.map(key);
handEl.innerHTML = "";
var fresh = 0;
cards.forEach(function (c, i) {
var el = card(c, { button: true });
el.style.setProperty("--i", i);
el.dataset.at = String(i);
var ok = yours && playable[i];
var ok = !!live && !!playable[i];
el.dataset.on = ok ? "1" : "0";
el.disabled = !ok || busy;
if (ok) el.addEventListener("click", function () { pick(i, c); });
var was = pool.indexOf(key(c));
if (was >= 0) {
pool.splice(was, 1);
el.dataset.settled = "1"; // it was already in your hand: don't deal it again
} else {
el.style.setProperty("--i", fresh++);
}
handEl.appendChild(el);
});
shownHand = cards.slice();
}
function renderHand(v) {
if (!v || !v.hand) { handEl.innerHTML = ""; shownHand = []; return; }
var playable = {};
(v.playable || []).forEach(function (i) { playable[i] = true; });
paintHand(v.hand, playable, v.turn === 0 && v.phase !== "done");
}
// showHand redraws your fan from the hand the server stamped on the event that
// just changed it — see Event.Hand in the engine.
//
// This is the fix for a hand that used to sit there stale for the whole lap.
// bump() keeps the *bots'* fans honest and has always refused seat zero, so
// when a +4 landed on you at the top of a lap you watched four backs fly into
// your hand and then nothing: the cards themselves only turned up seconds
// later, when the script ended and paint() finally ran. You were looking at a
// hand you no longer held.
function showHand(cards) {
if (!cards) return;
paintHand(cards, {}, false);
// And the line above the fan counts what's in it, so it moves too.
countEl.textContent = cards.length + (cards.length === 1 ? " card — UNO!" : " cards");
}
function renderPiles(v) {
@@ -357,7 +431,10 @@
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
// burst() is the room's win sound as well as its confetti; anything else is
// a night that didn't go your way.
if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
else FX.sfx("lose");
}
// controls is the one place that decides what you can click: whose turn the
@@ -373,6 +450,11 @@
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
b.disabled = busy || !yours || b.dataset.on !== "1";
});
// Catching is free, but it is still a move: it can't go up while one of yours
// is in flight, and it can't go up on somebody else's turn.
seatsEl.querySelectorAll("[data-catch]").forEach(function (b) {
b.disabled = busy || !yours;
});
// Under a stack you cannot draw. The deck is not a way out of a bill: the two
// moves that exist are answering it with a draw card or taking the lot.
if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack;
@@ -398,6 +480,7 @@
betting.classList.toggle("hidden", live);
playing.classList.toggle("hidden", !live);
hideWild();
hideGate();
if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
@@ -406,7 +489,13 @@
// this is the table not offering a button that cannot work.
var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
if (passBtn) passBtn.classList.toggle("hidden", !canPass);
if (hintEl && live) hintEl.textContent = HINTS[v.phase] || HINTS.play;
if (hintEl && live) {
// A seat sitting on one card it never called is the most valuable thing on
// the table, and it is worth more than whatever the phase was going to say.
hintEl.textContent = (v.catchable || []).length
? "Somebody's down to one card and never said so. Catch them before you play."
: (HINTS[v.phase] || HINTS.play);
}
if (!live && v) rulesFor(v); // it's over: leave the panel where the game was
controls();
if (!v || !v.outcome) verdictEl.classList.add("hidden");
@@ -426,7 +515,9 @@
// ---- the script ------------------------------------------------------------
// throwCard sends a card from one place to another across the felt.
// throwCard sends a card from one place to another across the felt. It lands
// with a slap — "card" for one going down on the pile, "deal" (softer, lower)
// for one coming off the deck into a hand, which is a card being *placed*.
function throwCard(node, from, to, opts) {
opts = opts || {};
return FX.flyNode(node, from, to, {
@@ -435,12 +526,17 @@
spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
delay: opts.delay || 0,
index: opts.index || 0,
sound: opts.sound === undefined ? "card" : opts.sound,
});
}
// bump keeps a bot's fan honest during the script: the server's count is the
// truth, but between events the fan has to grow and shrink as cards move, or
// the flight lands on a pile that hasn't changed.
//
// Seat zero is not a bot and is not bumped — a fan of backs is no use to you,
// you want the faces. showHand does yours, from the hand the event carries.
function bump(seat, left) {
if (seat === 0 || left == null) return;
var el = seatEl(seat);
@@ -499,6 +595,10 @@
// The deal isn't animated card by card: seven cards to each of four
// seats is 28 flights and a player who wants to play. The hand fans in
// on its own (CSS), which reads as being dealt without taking as long.
// The sound does the same job: a riffle, then cards landing, rather
// than twenty-eight separate slaps.
FX.sfx("shuffle");
for (var c = 0; c < 5; c++) FX.sfx("deal", { delay: 0.28 + c * 0.06, v: c });
paint(final);
return wait(320);
@@ -525,6 +625,7 @@
colourEl.dataset.c = e.color;
feltEl.dataset.c = e.color;
}
showHand(e.hand); // the card has landed; close the gap it left
return wait(e.seat === 0 ? 120 : 300);
});
}
@@ -535,14 +636,16 @@
var to = seatAnchor(e.seat);
var backs = [];
for (var i = 0; i < Math.min(e.n, 4); i++) {
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90, sound: "deal" }));
}
var punished = e.kind === "forced";
if (punished) FX.sfx("bad");
// A forced draw is also how a stack ends: somebody stopped answering
// and paid the bill, so the bill is gone.
if (punished) { pending(0); badge(e.seat, "+" + e.n, "bad"); }
return Promise.all(backs).then(function () {
bump(e.seat, e.left);
showHand(e.hand); // your cards, face up, as they land
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
// Your own drawn card comes face up — it's yours, and the server
// sent its face for exactly this.
@@ -551,12 +654,58 @@
});
}
// ---- the call, and the catch ---------------------------------------
//
// Somebody went down to one card without saying so, and somebody else
// noticed. Two cards off the deck, and the seat that took them wears the
// name of whoever spotted it — because "caught" with nobody attached is
// just a mystery.
case "caught": {
spotlight(e.seat);
var mine = e.seat === 0;
var caughtBy = (game && game.seats && game.seats[e.by]) || {};
var whom = mine
? "Caught! " + (caughtBy.name || "They") + " saw it"
: "Caught! +" + e.n;
// Catching a bot is a pointed finger; being caught is a thud.
FX.sfx(mine ? "bad" : "catch");
var lands = [];
var at = seatAnchor(e.seat);
for (var k = 0; k < e.n; k++) {
lands.push(throwCard(back(), deckEl, at, { index: k, delay: k * 100, sound: "deal" }));
}
badge(e.seat, whom, "bad");
return Promise.all(lands).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(520);
});
}
// You called a seat that had nothing to hide. Two cards, and they're
// yours: the price of the catch button not being a thing you just mash.
case "miscall": {
FX.sfx("bad");
var wrong = [];
for (var w = 0; w < e.n; w++) {
wrong.push(throwCard(back(), deckEl, handEl, { index: w, delay: w * 100, sound: "deal" }));
}
badge(0, "They called it. +" + e.n, "bad");
return Promise.all(wrong).then(function () {
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(520);
});
}
case "stack":
// The bill has moved on to somebody, and N is what it stands at now —
// not what was just added to it. A +2 answered with a +10 is a seat
// looking at twelve cards.
pending(e.n);
spotlight(e.seat);
FX.sfx("bad");
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
case "skipall":
@@ -580,7 +729,10 @@
dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 }));
}
badge(e.seat, "" + e.n + " " + (e.color || ""));
return Promise.all(dumped).then(function () { return wait(220); });
return Promise.all(dumped).then(function () {
showHand(e.hand);
return wait(220);
});
}
case "roulette": {
@@ -595,6 +747,7 @@
badge(e.seat, "Roulette +" + e.n, "bad");
return Promise.all(spun).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(400);
});
@@ -606,7 +759,9 @@
var grave = seatEl(e.seat);
if (grave) grave.dataset.out = "1";
bump(e.seat, 0);
showHand(e.hand); // if it was you, the hand goes with you
pending(0); // a dead seat pays no bill and leaves none behind
FX.sfx("bad");
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
}
@@ -618,6 +773,7 @@
return badge(e.seat, "Reverse").then(function () { return wait(60); });
case "uno":
FX.sfx("uno");
return badge(e.seat, "UNO!", "uno");
case "reshuffle":
@@ -626,6 +782,7 @@
// and counting down from a deck that still reads empty gets nowhere.
deckCntEl.textContent = String(e.n);
deckEl.classList.add("pete-uno-shuffle");
FX.sfx("shuffle");
return wait(420).then(function () {
deckEl.classList.remove("pete-uno-shuffle");
});
@@ -699,8 +856,72 @@
function pick(i, c) {
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
if (c.wild) { askColour(i); return; }
move({ kind: "play", index: i });
if (c.wild) { askColour(i); return; } // the colour first; the call comes after
offer(i, 0);
}
// offer is the last thing between a card and the pile. If playing it leaves you
// holding one card, the table wants a word out of you first.
//
// Which cards those are is the server's answer, not a count of your hand: No
// Mercy's "discard all" takes every card of its colour with it, so a six-card
// hand can land on one, and a browser subtracting one from six would let you
// walk into a catch it never warned you about.
function offer(i, color) {
if (game && (game.uno_at || []).indexOf(i) >= 0) {
openGate(i, color);
return;
}
move({ kind: "play", index: i, color: color });
}
function openGate(i, color) {
gate = { index: i, color: color };
gateEl.classList.remove("hidden");
if (timerEl) {
// Restart the drain. The bar is a CSS animation and this element gets
// reused, so without kicking the browser between the two assignments it
// stays where the last one finished — a full bar that never moves, which
// is worse than no bar at all.
timerEl.style.animation = "none";
void timerEl.offsetWidth;
timerEl.style.animation = "";
timerEl.style.animationDuration = CALL_MS + "ms";
}
if (callBtn) callBtn.focus();
gateTimer = setTimeout(function () { shut(false); }, CALL_MS);
}
// shut plays the card the gate was holding, with or without the word. Letting
// the clock run out is a real answer — it is the answer that gets you caught.
function shut(called) {
if (!gate) return;
var g = gate;
gate = null;
clearTimeout(gateTimer);
gateTimer = 0;
gateEl.classList.add("hidden");
move({ kind: "play", index: g.index, color: g.color, uno: called });
}
// hideGate drops the gate without playing anything. Only for the paths that tear
// the board down under it (an error, a resume) — a gate that outlived its game
// would play a card into the next one.
function hideGate() {
if (gateTimer) clearTimeout(gateTimer);
gateTimer = 0;
gate = null;
if (gateEl) gateEl.classList.add("hidden");
}
if (callBtn) callBtn.addEventListener("click", function () { shut(true); });
// Catching a seat that went quiet. It is not a turn — right or wrong, the turn
// stays with you — but it is a move, so it goes through the same door as one.
function catchSeat(seat) {
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
if ((game.catchable || []).indexOf(seat) < 0) return;
move({ kind: "catch", seat: seat });
}
function askColour(i) {
@@ -724,7 +945,7 @@
var i = pendingWild;
var c = COLOURS[b.dataset.colourPick];
hideWild();
move({ kind: "play", index: i, color: c });
offer(i, c); // named the colour; now, is it your last but one?
});
});