Ask for the hands at the showdown, the way the streets are already asked for

The card feature had two halves and only one of them ever asked a question. Turn the
setting on and every street opens a sheet for the cards coming off the deck — that
worked, on a phone and on a desk, in a normal hand and in an all-in run-out. The
other half never asked at all. A dealer who entered all five community cards, pressed
"Showdown — resolve pot", and got a wizard saying

    Christopher, Mike from work, Alexandra and Bob haven't turned over, so who
    won is still yours to say.

with nothing in that sheet to turn anybody over with. The only route in was to close
the wizard, tap a seat puck, find the two card slots in the seat editor, do it four
times and re-open the wizard, with the pot on the table and four people waiting. The
one moment the tool is certain to know the hands is the moment it stopped asking.

So the showdown button asks. The sheet opens on whoever the bar has just named as
showing first, takes their two cards, and moves round the table from there; hands
already entered are skipped, because a hand entered is a question already answered.
Four sheets later the wizard opens reading "Chris wins $40 with three of a kind,
nines" and its button says "Pay Chris" instead of "Confirm Winner(s)".

Leaving the sheet stops the asking rather than skipping a player: closing it says the
tool has the wrong end of the stick, and the answer to that is to get out of the way.
It isn't a dead end — the wizard still names whoever didn't turn over, the bar still
says "Showdown — resolve pot", and pressing it again resumes at the player the dealer
stopped on, because the list is read off the cards on the table rather than remembered.

CardPicker.close() takes a reason so the chain can tell "that sheet filled its last
slot" from "the dealer left". Two callers pass nothing and don't care.

The setting kept its behaviour and lost its jargon: "Track the board and read the
showdown" was the feature's name rather than what turning it on does to your evening.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-09 04:10:53 -06:00
co-authored by Claude Opus 5
parent e90bcccada
commit 75ec028c3e
6 changed files with 305 additions and 10 deletions
@@ -90,10 +90,13 @@
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<label class="fieldRow">
<span>Track the board and read the showdown</span>
<span>Ask me for the cards as they come out</span>
<input type="checkbox" id="trackBoardToggle" />
</label>
<p class="fieldHint">Puts the community cards on the felt and asks for them as each street is dealt. Turn the players&rsquo; cards over at showdown and Resolve Pot works out who won and how much, instead of asking you.</p>
@* The label used to lead with "Track the board", which is the feature's name
rather than what pressing it does to your evening. What it does is put a
card sheet in front of you at four moments: each street, and the showdown. *@
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over &mdash; then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
@@ -1169,6 +1169,20 @@ const ActionBar = {
return line;
},
// The cards come over before anybody is paid.
//
// Same shape as the street button above: when the dealer has asked to be asked, the
// moment they press this is the moment they are holding the hands, so the sheet
// opens here rather than leaving them to find the seat editor for each player with
// the pot already on the table. Off by default, and unchanged when it's off.
toShowdown() {
if (this.state.trackBoard && typeof Render !== "undefined") {
Render.openShowdownPicker(() => this.hooks.resolvePot());
return;
}
this.hooks.resolvePot();
},
renderStreetDone() {
const wrap = document.createElement("div");
@@ -1184,7 +1198,7 @@ const ActionBar = {
row.className = "actionButtons";
row.appendChild(this.button(label, "actionAdvance", () => {
if (isRiver) {
this.hooks.resolvePot();
this.toShowdown();
return;
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
@@ -1216,7 +1230,7 @@ const ActionBar = {
row.appendChild(this.button(
winner ? `Award pot to ${winner}` : "Resolve pot",
"actionAdvance",
() => this.hooks.resolvePot()
() => this.toShowdown()
));
wrap.appendChild(row);
return wrap;
@@ -68,8 +68,20 @@ const CardPicker = {
this.state.seats[this.seatIndex].cards[index] = card;
},
close() {
// Why the sheet is closing, because one caller needs to tell the difference.
//
// "filled" is the sheet finishing its own job — the last empty slot it was offering
// got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim.
// A single seat's cards don't care which. A showdown does: it walks the players one
// at a time, and the two answers are "next player" and "stop asking".
//
// The hooks are dropped before the callback runs, so a caller that opens the sheet
// again from inside `onDone` installs its own and this close can't fire twice.
close(reason = "dismissed") {
document.getElementById("cardPickerOverlay").hidden = true;
const done = this.hooks && this.hooks.onDone;
this.hooks = null;
if (done) done(reason);
},
// Every card already dealt somewhere — in front of a player or face up in the
@@ -110,7 +122,7 @@ const CardPicker = {
this.render();
return;
}
this.close();
this.close("filled");
},
clearSlot() {
@@ -175,10 +175,18 @@ const Render = {
misdeal.hidden = !this.state.bettingOpen;
// On a table nobody is sitting at, Next Hand has no button to move and Resolve
// Pot has no pot — but Resolve Pot is the accent green, so the loudest control
// on a new dealer's first screen was the one that could do the least. It is
// now visibly not-yet, which puts the only green left on the felt where the
// actual next step is.
// Pot has no pot, so both are visibly not-yet rather than sitting there looking
// ready.
//
// This used to be half of a colour argument as well — Resolve Pot was the accent
// green, so the loudest control on a new dealer's first screen was the one that
// could do the least. The disabling fixed that for the empty table and left it
// everywhere else: measured over a whole hand, the header was green at all
// seventeen steps, including the four where the bar held the only button there
// was to press. The accent has since gone to the bar's own next-step button,
// where it belongs at every stage rather than at one of them, so what is left
// here is only the part about what a control can do — which is still worth
// saying, because a shortcut that cannot work yet should not look ready.
//
// Disabled rather than hidden: hiding them would move the header the moment
// the first player is named, and a control that vanishes teaches nothing about
@@ -1748,6 +1756,67 @@ const Render = {
}, at, showing);
},
// Who still has cards to turn over, in the order they turn them over.
//
// The board half of this feature has always worked: every street asks for the cards
// as they come off the deck. The hands half never did — the dealer entered five
// community cards, pressed the showdown button, and the pot wizard told them
// "Chris, Mike and Alex haven't turned over, so who won is still yours to say",
// with nowhere in the sheet to turn anybody over. The one moment the tool is certain
// to know the hands is the moment it stopped asking.
//
// Order is the showdown order the bar already announces out loud, then round the
// table from there — the sequence the cards actually come over in, so the dealer is
// never hunting for the seat the sheet has decided to ask about next.
//
// Anyone already entered is skipped. A dealer who typed a hand into the seat editor
// mid-hand has answered this question, and asking again would offer to overwrite it.
showdownCardQueue(state) {
if (!state.trackBoard) return [];
const live = Betting.liveSeats(state);
if (live.length < 2) return [];
const order = HoldemState.liveOrderFromButton(state)
.filter(index => live.some(seat => seat.index === index));
const first = Betting.showdownOrder(state);
const at = first ? order.indexOf(first.index) : -1;
const rotated = at > 0 ? order.slice(at).concat(order.slice(0, at)) : order;
return rotated.filter(index => {
const cards = state.seats[index].cards || [];
return !(cards[0] && cards[1]);
});
},
// Asks each of them for their two cards, one player after another, and then hands
// back so the pot can be resolved.
//
// Leaving the sheet stops the asking rather than skipping to the next player: a
// dealer who closes it is telling the tool it has the wrong end of the stick, and
// the answer to that is to get out of the way and let them settle the pot by hand,
// not to put the same sheet in front of them four more times. The wizard still
// names whoever didn't turn over, so nothing is lost by stopping early.
openShowdownPicker(onFinished) {
const queue = this.showdownCardQueue(this.state);
const finish = () => onFinished();
if (queue.length === 0) { finish(); return; }
const askNext = () => {
const index = queue.shift();
if (index === undefined) { finish(); return; }
const cards = this.state.seats[index].cards || [];
const slot = cards[0] ? 1 : 0;
CardPicker.open(this.state, {
onPick: () => {
HoldemState.save(this.state);
this.renderAll();
},
onDone: reason => (reason === "filled" ? askNext() : finish()),
}, index, slot);
};
askNext();
},
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
// Worth calling out, because at a short-handed table the open seats are what
// the dealer is looking for.
+50
View File
@@ -571,6 +571,56 @@ narrowing fix has to be shown not to have blinded the thing it quietened, so the
broken versions are re-run **with the Keyboard panel hidden** — the condition that was
breaking it — and each is still caught, while the unbroken page stays clean.
## Half a feature, and the half that was missing
The card feature has always had two halves. Turn on the setting and every street asks
which cards came off the deck, and puts them on the felt. That half worked, on the
phone and on the desk, in a normal hand and in an all-in run-out.
The other half never got asked for at all. A dealer who entered all five community
cards, pressed **Showdown — resolve pot**, and got a wizard that said
> Christopher, Mike from work, Alexandra and Bob haven't turned over, so who won is
> still yours to say.
with nothing anywhere in that sheet to turn anybody over with. The only route in was to
close the wizard, tap a seat puck, find the two card slots in the seat editor, do it
four times, and re-open the wizard — with the pot on the table and four people waiting.
The one moment the tool is certain to know the hands is the moment it stopped asking.
So the showdown button now asks, exactly the way the street button does: the sheet opens
on the first player, takes two cards, and moves to the next. `showdownCards.test.js` is
the list it asks from — who is on it and in what order — and three rules hold it
together, one test each way:
**Only when the dealer asked.** The setting is off by default, and with it off the list
is empty. A table that never wanted this never sees a sheet.
**Only what isn't known.** A hand already entered is an answer already given. Asking
again offers to overwrite it, so an entered hand drops out of the list and the rest keep
their order.
**In the order the cards come over.** The bar already announces "Alexandra shows first —
last to bet or raise" a line above the button. Asking about somebody else first would be
the tool contradicting itself in the same breath, so the list starts where the bar says
and goes round the table from there.
### The sheet has to be leavable, and leaving it has to mean something
Escape, Done and a tap on the scrim all closed the picker already. What they didn't have
was a way to say *why*, and a chain of four sheets needs to know the difference: a sheet
that filled its last slot means "next player", and a dealer closing one means "stop".
So `close()` takes a reason. Two callers pass nothing and don't care. The showdown chain
does, and it stops rather than skipping — a dealer who closes the sheet is saying the
tool has the wrong end of the stick, and the answer to that is to get out of the way, not
to put the same sheet in front of them three more times.
Stopping early is not a dead end, which is the part worth checking by hand. The wizard
still names whoever didn't turn over; closing it leaves **Showdown — resolve pot** on the
bar; and pressing it again resumes at the player the dealer stopped on, because the list
is computed from the cards on the table rather than remembered from the last attempt.
## Why the hand evaluator is tested the way it is
`handRank.js` is the only module here that can pay the wrong person without anybody
+147
View File
@@ -0,0 +1,147 @@
// Who the tool asks to turn over, and in what order.
//
// The board half of the card feature always worked: every street, a sheet asks which
// cards came off the deck. The hands half never did. A dealer entered five community
// cards, pressed the showdown button, and the pot wizard told them "Chris, Mike and
// Alex haven't turned over, so who won is still yours to say" — with nothing in the
// sheet to turn anybody over with. The only way in was to close the wizard, tap a seat
// puck, find the card slots in the seat editor, and repeat it for each player, with the
// pot sitting on the table.
//
// So the showdown button now asks, the same way the street button does. This file is
// the list it asks from. The sheet itself is DOM and is checked in the browser; the
// question of who is on the list, and in which order, is state and lives here.
//
// Three rules hold it together:
//
// Only when asked for. The setting is off by default and the list is empty when it
// is off, so a table that never wanted this never sees a sheet.
//
// Only what isn't known. A hand already entered is an answer already given, and
// asking again offers to overwrite it.
//
// In the order the cards actually come over — the showdown order the action bar
// already announces, then round the table from there. A dealer looking at the seat
// the bar just named should not be asked about the one behind them.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, Betting, HoldemState, makeTable } = require("./load");
// A hand played to the river with everyone still in, which is the only shape that
// has a showdown to ask about.
const atShowdown = ({ seats = 4, tracking = true } = {}) => {
const state = makeTable({ seatCount: seats, stack: 500, smallBlind: 5, bigBlind: 10 });
Betting.beginHand(state);
state.street = "river";
state.board = ["2C", "7D", "9S", "JH", "3S"];
state.trackBoard = tracking;
state.seats.forEach(player => { player.hasActed = true; });
return state;
};
const queue = state => Render.showdownCardQueue(state);
describe("only when the dealer asked to be asked", () => {
test("the setting off means nobody is asked anything", () => {
assert.deepStrictEqual(queue(atShowdown({ tracking: false })), []);
});
test("the setting on asks everyone still in", () => {
assert.strictEqual(queue(atShowdown()).length, 4);
});
test("a table nobody is sitting at is not a showdown", () => {
const state = atShowdown();
state.seats.forEach(player => { player.inHand = false; });
assert.deepStrictEqual(queue(state), []);
});
});
describe("a hand won without a showdown", () => {
// Everyone else folded, so there is one hand left and it never comes over. The
// bar says "Award pot to Chris" here, not "Showdown", and asking Chris to turn
// over a hand nobody paid to see is asking for a card the tool will never use.
test("one player left is asked for nothing", () => {
const state = atShowdown();
[1, 2, 3].forEach(i => { state.seats[i].folded = true; });
assert.deepStrictEqual(queue(state), []);
});
test("two left is a showdown, however it was arrived at", () => {
const state = atShowdown();
[2, 3].forEach(i => { state.seats[i].folded = true; });
assert.strictEqual(queue(state).length, 2);
});
// Everybody all-in on the flop is still a showdown: the cards go on their backs,
// the board runs out, and the hands are read exactly as they would have been.
test("all-in with the betting finished is still asked about", () => {
const state = atShowdown();
state.seats.forEach(player => { player.stack = 0; });
assert.ok(!Betting.canStillBet(state));
assert.strictEqual(queue(state).length, 4);
});
});
describe("only the hands it doesn't already have", () => {
test("a hand already entered is not asked for again", () => {
const state = atShowdown();
state.seats[1].cards = ["AS", "AH"];
assert.ok(!queue(state).includes(1));
});
test("half a hand entered is still asked for", () => {
const state = atShowdown();
state.seats[1].cards = ["AS", ""];
assert.ok(queue(state).includes(1));
});
test("every hand entered means the sheet never opens", () => {
const state = atShowdown();
const hands = [["AS", "AH"], ["KS", "KH"], ["QS", "QH"], ["9H", "9D"]];
state.seats.forEach((player, i) => { player.cards = hands[i]; });
assert.deepStrictEqual(queue(state), []);
});
});
describe("in the order the cards come over", () => {
// The bar already says "Mike shows first — last to bet or raise" out loud. The
// sheet asking about somebody else first would be the tool contradicting itself
// in the same breath.
test("it starts with whoever the bar says shows first", () => {
const state = atShowdown();
state.streetAggressorIndex = 2;
assert.strictEqual(Betting.showdownOrder(state).index, 2);
assert.strictEqual(queue(state)[0], 2);
});
test("and then goes round the table from there", () => {
const state = atShowdown();
state.streetAggressorIndex = 2;
assert.deepStrictEqual(queue(state), [2, 3, 0, 1]);
});
test("nobody checked the river, so it starts left of the button", () => {
const state = atShowdown();
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
const first = Betting.showdownOrder(state);
assert.strictEqual(first.reason, "leftOfButton");
assert.strictEqual(queue(state)[0], first.index);
});
test("a player already entered drops out of the order without moving the rest", () => {
const state = atShowdown();
state.streetAggressorIndex = 2;
state.seats[3].cards = ["KS", "KH"];
assert.deepStrictEqual(queue(state), [2, 0, 1]);
});
test("everyone still in is asked exactly once", () => {
const state = atShowdown({ seats: 9 });
const asked = queue(state);
assert.strictEqual(new Set(asked).size, asked.length);
assert.strictEqual(asked.length, Betting.liveSeats(state).length);
});
});