From 784315f2ea5141b4320426c58640066099ed9d51 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 12:16:00 -0600
Subject: [PATCH 01/55] Put one dealer button on the table
A poker table has one dealer button. This one drew nine.
Every seat carried a puck: the real one in gold, and eight ghosts at 0.32 opacity
that existed to be pressed. Measured on a nine-seat table -- eight pale discs, all
eight showing the letter D, one of them the button. Finding it meant comparing nine
identical shapes for the one that's gold, when it should be the only thing of its
kind on the felt.
The ghosts also sat on their neighbours. Every remaining "something important is
covered" offender on a crowded table was a live seat covered by the seat-next-door's
ghost puck: 844x390, 915x412, 360x640 and 640x360 each reported one, and they were
all the same thing.
And a screen reader heard nine buttons named "D".
So a seat without the button doesn't draw one. Hovering a seat fades its slot in --
a preview of where the button would go, one click as before -- and on touch, where
there is no hover, "Give the button" has been in the seat's long-press menu all
along. Keyboard reaches it by focus, where the slot appears too.
pointer-events matters as much as the opacity: an invisible puck that still took
taps would be worse than a visible one.
visible pucks 9 -> 1 at every size
layout offenders 915x412 1->0, 360x640 1->0, 844x390 3->2, 640x360 3->2
clean viewports 4 of 9 -> 6 of 9
labels nine "D" -> "Ann has the dealer button" and
"Give the dealer button to Ben"
The audit now checks it, because this is a rule about the table rather than about
CSS: one puck drawn, and no two announcing themselves the same way. Verified against
the old behaviour put back -- it reports 9 and 9.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 2 +-
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 33 ++++++++++++++++---
.../wwwroot/js/casino/holdem/render.js | 12 ++++++-
tests/holdem/uiAudit.js | 29 ++++++++++++++++
4 files changed, 69 insertions(+), 7 deletions(-)
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 36c00b3..7973bb2 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -163,7 +163,7 @@
-
+
SBBB
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 8b31fb5..2ffd186 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -591,7 +591,6 @@ button,
font-size: 0.68rem;
cursor: pointer;
z-index: 2;
- opacity: 0.32;
transition: opacity 0.15s ease;
/* A dealer puck should look like a puck — small, sitting on the rim of its seat.
But tapping it hands the button to that seat and re-posts the blinds, and 22px
@@ -622,16 +621,40 @@ button,
left: -18px;
}
-.dealerChip:focus-visible {
- opacity: 1;
+/* A poker table has one dealer button. This one drew nine.
+
+ Every seat carried a puck — the button's, and eight ghosts at 0.32 opacity that
+ were there to be pressed. Measured on a nine-seat table: eight pale discs, all
+ eight showing the letter D, one of them real. Finding the button meant comparing
+ nine identical shapes for the one that's gold, when it should be the only thing
+ of its kind on the felt. The ghosts also sat on top of their neighbours: every
+ remaining "something important is covered" offender on a crowded table was a
+ seat covered by the seat-next-door's ghost puck.
+
+ So a seat that doesn't have the button doesn't draw one. The affordance is still
+ there — hovering a seat fades its slot in, which is a preview of where the button
+ would go — and on touch, where there is no hover, "Give the button" has always
+ been in the seat's long-press menu. Keyboard reaches it by focus.
+
+ pointer-events matters as much as the opacity: an invisible puck that still takes
+ taps would be worse than a visible one. */
+.dealerChip:not(.active) {
+ opacity: 0;
+ pointer-events: none;
}
@media (hover: hover) {
- .seat:hover .dealerChip {
- opacity: 1;
+ .seat:hover .dealerChip:not(.active) {
+ opacity: 0.5;
+ pointer-events: auto;
}
}
+.dealerChip:not(.active):focus-visible {
+ opacity: 1;
+ pointer-events: auto;
+}
+
.dealerChip.active {
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
border-color: #a9782f;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 7956fd1..82c22bc 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -174,8 +174,18 @@ const Render = {
node.querySelector(".seatChairNumber").textContent = index + 1;
const dealerChip = node.querySelector(".dealerChip");
- dealerChip.classList.toggle("active", index === dealerIndex);
+ const hasButton = index === dealerIndex;
+ dealerChip.classList.toggle("active", hasButton);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
+ // Every one of these announced itself as "D", so a screen reader heard a row
+ // of nine identical buttons and couldn't tell which seat held the real one.
+ // The label now says whose it is, or what pressing it would do.
+ const who = (player.name || "").trim() || `seat ${index + 1}`;
+ const chipLabel = hasButton
+ ? `${who} has the dealer button`
+ : `Give the dealer button to ${who}`;
+ dealerChip.setAttribute("aria-label", chipLabel);
+ dealerChip.title = chipLabel;
// Tapping the puck is the same act as "Give the button" in the seat menu,
// so it goes through the same hook — otherwise this one path would move
// the button without moving the blinds that follow it.
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index bcd0b86..4034c58 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -514,6 +514,34 @@ const LayoutAudit = {
return { name: "a seat's centre belongs to that seat", offenders };
},
+ // A poker table has one dealer button. Nine pucks all showing "D", one of them
+ // gold, turns the most-read marker on the felt into a spot-the-difference — and
+ // a screen reader heard nine buttons named "D" with nothing to tell them apart.
+ // Both halves are checked here: what's drawn, and what it announces itself as.
+ oneDealerButton() {
+ const chips = [...document.querySelectorAll(".dealerChip")];
+ const shown = chips.filter(chip => {
+ const style = getComputedStyle(chip);
+ return style.display !== "none" && style.visibility !== "hidden" &&
+ parseFloat(style.opacity) > 0.05;
+ });
+ const offenders = [];
+
+ if (shown.length > 1) {
+ offenders.push(`${shown.length} dealer buttons drawn on the felt at once`);
+ }
+
+ const labels = chips.map(chip => chip.getAttribute("aria-label") || chip.textContent.trim());
+ const duplicated = labels.filter((label, i) => labels.indexOf(label) !== i);
+ if (duplicated.length) {
+ offenders.push(
+ `${duplicated.length + 1} dealer buttons announce themselves as "${duplicated[0]}"`
+ );
+ }
+
+ return { name: "the felt shows one dealer button", offenders };
+ },
+
// ---- Runner ----
runAll(options = {}) {
@@ -527,6 +555,7 @@ const LayoutAudit = {
this.horizontalOverflow(),
this.fieldFontSize(),
this.seatOwnership(),
+ this.oneDealerButton(),
];
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
From 4c1ac222a8f80f7fc4e99d920e5e2c1e38a3d021 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 12:16:15 -0600
Subject: [PATCH 02/55] Give Enter and Space back to the control that has focus
Found while checking the keyboard could still reach the dealer button: it could
reach it and then couldn't press it.
Enter and Space were bound to "advance" for the whole document, with an exception
only for text fields. Those two keys are how a keyboard presses the control it is
sitting on, so every button on the page lost them -- and not to nothing. Measured:
Tab to Resolve Pot, press Enter -> the table dealt the next hand
Tab to Resolve Pot, press Space -> Resolve Pot, correctly
Tab to a dealer button, Enter -> nothing at all
The inconsistency is the shortcut handler's return value. It calls preventDefault
only when it handled the key, so when "advance" applied it ran instead of the button,
and when it didn't apply the browser's own activation got through. A control that
does something else every other press is worse than one that does nothing.
Enter and Space now go to a focused button, [role=button], link or summary, and the
shortcut only claims them when nothing pressable has focus. The letters stay live
with a button focused -- a button does nothing with "f", and a dealer whose hand is
on the keys shouldn't lose them to a stray focus ring.
Space still advances the street: dealt, f, f, c, c, Space -> flop. The action bar
rebuilds itself after every press, so focus falls back to the body and the dealer's
Space is never quietly aimed at the Fold they just clicked.
11 tests (163 total), and they fail on the old handler -- 4 of them, exactly the
Enter/Space-on-a-control cases.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/shortcuts.js | 18 +++
tests/holdem/shortcuts.test.js | 110 ++++++++++++++++++
2 files changed, 128 insertions(+)
create mode 100644 tests/holdem/shortcuts.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js
index 2a767b0..4843a64 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js
@@ -40,6 +40,22 @@ const Shortcuts = {
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
},
+ // Enter and Space are how a keyboard presses the control it is already sitting on.
+ // Claiming them for "advance" didn't just block that press — it ran a different
+ // action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
+ // the next hand. Space on the same button worked, because "advance" happened to
+ // decline that time and the browser's own activation got through. A control that
+ // does something else every other press is worse than one that does nothing.
+ //
+ // Only these two keys are handed back. The letter shortcuts stay live with a
+ // button focused, because a button does nothing with "f" and the dealer's hand
+ // shouldn't have to leave them.
+ focusedControlOwnsKey(key) {
+ if (key !== "enter" && key !== " ") return false;
+ const el = document.activeElement;
+ return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
+ },
+
onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -47,6 +63,8 @@ const Shortcuts = {
const action = this.KEYS[key];
if (!action) return;
+ if (this.focusedControlOwnsKey(key)) return;
+
// Escape is the exception: it should still close the raise pad even if focus
// happens to be sitting in its amount field.
if (this.isTyping() && !(action === "cancel")) return;
diff --git a/tests/holdem/shortcuts.test.js b/tests/holdem/shortcuts.test.js
new file mode 100644
index 0000000..931db39
--- /dev/null
+++ b/tests/holdem/shortcuts.test.js
@@ -0,0 +1,110 @@
+// The keyboard shortcuts, and the one rule that keeps them from stealing a press.
+//
+// Shortcuts talks to `document` directly, so these tests stand a small fake one up
+// rather than a whole DOM: the only things it asks about are what has focus and
+// whether an overlay is open.
+const test = require("node:test");
+const assert = require("node:assert");
+const path = require("path");
+
+const Shortcuts = require(path.join(
+ __dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
+
+// A stand-in for a focused element. `matches` answers the same selector list the
+// real one would, which is the whole of what focusedControlOwnsKey looks at.
+function element(tagName, { selectors = [], isContentEditable = false } = {}) {
+ return {
+ tagName,
+ isContentEditable,
+ matches: selector => selectors.some(s => selector.includes(s)),
+ };
+}
+
+const BUTTON = element("BUTTON", { selectors: ["button"] });
+const SUMMARY = element("SUMMARY", { selectors: ["summary"] });
+const LINK = element("A", { selectors: ["a[href]"] });
+const DIV = element("DIV");
+const TEXT_FIELD = element("INPUT");
+
+// Runs one keypress against a fresh Shortcuts and reports which action fired.
+function press(key, { focused = DIV, overlays = [] } = {}) {
+ const fired = [];
+ let defaultPrevented = false;
+
+ globalThis.document = {
+ activeElement: focused,
+ addEventListener() {},
+ querySelectorAll: () => overlays,
+ };
+
+ const handlers = {};
+ for (const action of Object.values(Shortcuts.KEYS)) {
+ handlers[action] = () => { fired.push(action); return true; };
+ }
+ Shortcuts.handlers = handlers;
+ Shortcuts.onKey({ key, preventDefault: () => { defaultPrevented = true; } });
+
+ return { fired: fired[0] || null, defaultPrevented };
+}
+
+test.after(() => { delete globalThis.document; });
+
+test("Enter on a focused button is left to the button", () => {
+ assert.equal(press("Enter", { focused: BUTTON }).fired, null);
+});
+
+test("Space on a focused button is left to the button", () => {
+ assert.equal(press(" ", { focused: BUTTON }).fired, null);
+});
+
+test("a key left to the control is not swallowed either", () => {
+ // preventDefault would stop the browser activating the button, which is the
+ // half of the bug that turned "wrong action" into "no action".
+ assert.equal(press("Enter", { focused: BUTTON }).defaultPrevented, false);
+});
+
+test("summary and links own Enter and Space too", () => {
+ assert.equal(press("Enter", { focused: SUMMARY }).fired, null);
+ assert.equal(press(" ", { focused: LINK }).fired, null);
+});
+
+test("Space still advances when nothing pressable has focus", () => {
+ assert.equal(press(" ", { focused: DIV }).fired, "advance");
+});
+
+test("Enter still advances when nothing pressable has focus", () => {
+ assert.equal(press("Enter", { focused: DIV }).fired, "advance");
+});
+
+test("the letter shortcuts stay live with a button focused", () => {
+ // A button does nothing with "f", so there is no press to hand back — and a
+ // dealer whose hand is on the keys shouldn't lose them to a stray focus ring.
+ assert.equal(press("f", { focused: BUTTON }).fired, "fold");
+ assert.equal(press("c", { focused: BUTTON }).fired, "checkOrCall");
+ assert.equal(press("r", { focused: BUTTON }).fired, "raise");
+ assert.equal(press("a", { focused: BUTTON }).fired, "allIn");
+ assert.equal(press("u", { focused: BUTTON }).fired, "undo");
+});
+
+test("typing still silences everything but Escape", () => {
+ assert.equal(press("f", { focused: TEXT_FIELD }).fired, null);
+ assert.equal(press("Escape", { focused: TEXT_FIELD }).fired, "cancel");
+});
+
+test("an open overlay still owns the keyboard", () => {
+ assert.equal(press("f", { focused: DIV, overlays: [{ hidden: false }] }).fired, null);
+ assert.equal(press("f", { focused: DIV, overlays: [{ hidden: true }] }).fired, "fold");
+});
+
+test("a focused control wins before the overlay check, not after", () => {
+ // Both would decline here; the point is that neither path fires an action.
+ assert.equal(press("Enter", { focused: BUTTON, overlays: [{ hidden: false }] }).fired, null);
+});
+
+test("modified keys are never shortcuts", () => {
+ globalThis.document = { activeElement: DIV, addEventListener() {}, querySelectorAll: () => [] };
+ const fired = [];
+ Shortcuts.handlers = { advance: () => { fired.push("advance"); return true; } };
+ Shortcuts.onKey({ key: " ", ctrlKey: true, preventDefault() {} });
+ assert.deepEqual(fired, []);
+});
From 29188d60499e9ed7f1e136038dfce1b565113f9e Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 12:34:53 -0600
Subject: [PATCH 03/55] Say ALL IN on the felt
A player with nothing behind had a blank where their stack goes. So did a seat whose
stack nobody had entered. On a table with side pots that is the one distinction the
dealer is working from -- who can still be bet at, and who is already in for
everything they have -- and the felt drew the two states identically.
Ann $40 $160 has chips
Ben $40 all-in for 40
Cal $40 $160 has chips
Dee $12 all-in for 12
The action bar knew: it had already built "Main pot $48, Ann/Ben/Cal/Dee" and
"Side pot 1 $84, Ann/Ben/Cal". The table it sits under didn't say a word.
Betting.isAllIn is the same stack test nextToAct uses to pass over a player, which is
the point: the seat the felt marks is exactly the seat the dealer will never be asked
to act on again. There's a test asserting that equivalence rather than restating the
condition.
The second half of the condition matters. A seat with an empty stack that has put
nothing in is a busted player who shouldn't have been dealt in, not a player who
moved all-in, so it isn't marked.
"ALL IN" in gold where the stack was, and a warmer border on the seat -- deliberately
quieter than the acting-seat ring, because all-in is a standing fact about a player
rather than a request for attention.
9 tests (172 total). They fail on a naive condition: dropping the committed-chips and
folded checks fails 4 of them.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/betting.js | 14 +++
.../wwwroot/js/casino/holdem/render.js | 9 +-
tests/holdem/allInMarker.test.js | 98 +++++++++++++++++++
3 files changed, 120 insertions(+), 1 deletion(-)
create mode 100644 tests/holdem/allInMarker.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 734ba37..8302997 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -117,6 +117,20 @@ const Betting = {
return null;
},
+ // A player still in the hand with chips in the pot and nothing left behind. This is
+ // the same stack test nextToAct uses to pass over them, which is the point: the
+ // seat the felt marks all-in is exactly the seat the dealer will never be asked to
+ // act on again.
+ //
+ // The "chips in the pot" half matters. A seat with an empty stack who hasn't put
+ // anything in is a busted player who shouldn't have been dealt in, not a player
+ // who moved all-in, and calling them all-in on the felt would be a lie.
+ isAllIn(state, player) {
+ if (!player || !player.inHand || player.folded) return false;
+ return this.handTotal(player) > this.EPSILON_CHIPS &&
+ this.stackOf(state, player) <= this.EPSILON_CHIPS;
+ },
+
// Which chair sits `step` places along in the direction of play. The table owns
// this — walking the seats is exactly where the button, the blinds and the action
// used to drift apart from each other, so there is one implementation of it.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 82c22bc..4410dc7 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -279,11 +279,18 @@ const Render = {
totalEl.textContent = total > 0 ? Money.format(total) : "";
this.fitAmount(totalEl);
+ // A player with nothing behind used to leave this line blank, so the felt drew
+ // an all-in exactly like a seat whose stack nobody had entered. On a table with
+ // side pots that's the one distinction the dealer is working from — who can
+ // still be bet at, and who is already committed for everything they have.
const stackEl = node.querySelector(".seatPuckStack");
const stack = Betting.stackOf(this.state, player);
- stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
+ const allIn = Betting.isAllIn(this.state, player);
+ stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
+ stackEl.classList.toggle("seatPuckAllIn", allIn);
this.fitAmount(stackEl);
+ node.classList.toggle("seatAllIn", allIn);
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
node.classList.toggle("seatEmpty", empty);
node.classList.toggle("seatInactive", !player.inHand && !empty);
diff --git a/tests/holdem/allInMarker.test.js b/tests/holdem/allInMarker.test.js
new file mode 100644
index 0000000..12c2f9c
--- /dev/null
+++ b/tests/holdem/allInMarker.test.js
@@ -0,0 +1,98 @@
+// Who the felt marks "ALL IN".
+//
+// The mark's whole value is that a dealer can scan the ring and see who can still be
+// bet at, so the test that matters most is the last one: the seats it marks are
+// exactly the seats the betting round refuses to ask for another chip.
+const test = require("node:test");
+const assert = require("node:assert");
+const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
+
+test("a player who moved all-in is marked", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
+ state.seats[3].stack = 12;
+ Betting.beginHand(state);
+ Betting.allIn(state, state.actingSeatIndex);
+
+ assert.equal(Betting.isAllIn(state, state.seats[3]), true);
+ assert.equal(state.seats[3].stack, 0);
+});
+
+test("a player with chips behind is not", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ Betting.beginHand(state);
+ assert.equal(Betting.isAllIn(state, state.seats[0]), false);
+ assert.equal(Betting.isAllIn(state, state.seats[1]), false);
+});
+
+test("a busted player who was dealt in but has put nothing in is not", () => {
+ // An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
+ // in. Calling that "ALL IN" on the felt would be a lie about a live pot.
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
+ state.seats[2].stack = 0;
+ assert.equal(Betting.isAllIn(state, state.seats[2]), false);
+});
+
+test("a folded player with an empty stack is not", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
+ state.seats[1].committed = 5;
+ state.seats[1].stack = 0;
+ state.seats[1].folded = true;
+ assert.equal(Betting.isAllIn(state, state.seats[1]), false);
+});
+
+test("an empty chair is not", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+ assert.equal(Betting.isAllIn(state, state.seats[4]), false);
+});
+
+test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
+ state.defaultStack = 200;
+ state.seats[2].stack = null;
+ state.seats[2].committed = 10;
+ assert.equal(Betting.isAllIn(state, state.seats[2]), false);
+});
+
+test("the seats marked are exactly the ones the action passes over", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
+ state.seats[1].stack = 8;
+ state.seats[3].stack = 30;
+ Betting.beginHand(state);
+
+ // Everyone in, two of them for less than the others can cover.
+ for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
+
+ const marked = state.seats
+ .map((player, index) => (Betting.isAllIn(state, player) ? index : null))
+ .filter(index => index !== null);
+ const skipped = state.seats
+ .map((player, index) =>
+ player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
+ .filter(index => index !== null);
+
+ assert.deepEqual(marked, skipped);
+ assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
+});
+
+test("nobody is marked once the next hand starts", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
+ state.seats[2].stack = 5;
+ Betting.beginHand(state);
+ Betting.allIn(state, state.actingSeatIndex);
+ assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
+
+ // The short stack got there by winning nothing; give them chips back the way a
+ // dealer would before the next deal.
+ HoldemState.startNextHand(state);
+ state.seats[2].stack = 50;
+ assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
+});
+
+test("marking nobody doesn't move a chip", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
+ state.seats[3].stack = 12;
+ Betting.beginHand(state);
+ const before = chipsOnTable(state);
+ state.seats.forEach(player => Betting.isAllIn(state, player));
+ assert.equal(chipsOnTable(state), before);
+});
From 3d1ce1f45ca78da420bfe6757fc33063a7919965 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 12:35:15 -0600
Subject: [PATCH 04/55] Keep the table above the action bar on wide screens
The bar publishes its height so the layout can stay clear of it. Only the phone and
landscape rules ever read it. The wide layout sized the felt from its own width -- a
box 72% as tall as it is wide -- and so had no idea what was underneath it.
That was survivable while the bar was a constant. It isn't: it grows from 117px to
172px the moment a hand needs a side pot, which is exactly the hand where the dealer
most needs to see who is still in. Measured with two side pots, six seats, the bottom
seat sat this far behind the bar:
1280x900 22px 1600x1000 54px
768x1024 23px 1024x768 clear
360x640 39px 390x844 clear
The first attempt at this didn't work and the failure is worth recording: the wrap was
a padding-bottom: 72% box, and percentage padding resolves against the containing
block's width rather than the element's own. Capping the width narrowed the felt from
822px to 746px and left it exactly 617px tall, so the seat stayed where it was. It is
an aspect-ratio box now, and the shape is stated once as --feltAspect so the height
and the cap both derive from it.
1280x900 felt 857x617 -> 746x537 seat 22px behind the bar -> 51px clear
1600x1000 ...760 -> 885x637 54px behind -> 59px clear
768x1024 felt 689x689 -> 603x603 23px behind -> 56px clear
Six-seat tables at 1280x900, 1600x1000, 1024x768 and 768x1024 all go from one covered
seat to a clean audit.
Not applied below 700px. The narrow layout already had an answer -- it reserves the
bar's height as scroll room under the table, and a phone is a thing you scroll -- and
capping there was measured and rejected: at 360x640 it took the felt from 321px to
226px, which fixed the six-seat ring and broke the nine-seat one, two covered seats
traded for two seats whose centre landed on a neighbour.
Landscape phones opt out too. They set their own height from the bar already and are
sitting on their 7rem floor; 844x390 and 667x375 still have the bottom seat in the
bar, and nothing about a 112px-tall table with 73px seats can fix that.
The seat overhang is a constant rather than something measured off the layout it is
about to change -- a seat is ~73px tall at every width -- so this settles in one pass
instead of asking a different question each time it shrinks the felt.
172 tests still passing; no console errors; contrast clean.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 51 +++++++++++++++++--
.../wwwroot/js/casino/holdem/actionBar.js | 31 +++++++++++
2 files changed, 79 insertions(+), 3 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 2ffd186..8fe6162 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -308,9 +308,22 @@ button,
#pokerTableWrap {
position: relative;
+ /* The shape is stated once, and both the height and the cap below are derived from
+ it. It used to be a `padding-bottom: 72%` box, which has a trap in it: percentage
+ padding resolves against the *containing block's* width, not the element's own,
+ so narrowing the wrap left it exactly as tall as before. Capping the width was
+ therefore no way to shorten the table — measured, after trying it: the felt went
+ 822px wide to 746px and stayed 617px tall, and the bottom seat sat as far into
+ the action bar as it had. aspect-ratio ties the two together properly. */
+ --feltAspect: 0.72;
width: 100%;
- padding-bottom: 72%;
+ aspect-ratio: 1 / var(--feltAspect);
margin: 0 auto;
+ /* Capping the width is how the felt is told to stay above the action bar.
+ --feltRoomPx is published by actionBar.js with the bar's real height and the
+ bottom seat's overhang already taken out; the fallback leaves the table its full
+ size if that ever fails to arrive. */
+ max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
}
@@ -2126,6 +2139,23 @@ body.hasActionBar #holdemSidebar {
margin-top: 0.1rem;
}
+/* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
+ letters and a warm colour separate it from the grey money above it at a glance —
+ which is the whole job, because the dealer is scanning the ring for who can still
+ be bet at. */
+.seatPuckStack.seatPuckAllIn {
+ color: #ffcf6b;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+}
+
+/* And the seat itself carries a hint of it, so the mark survives being read out of
+ the corner of an eye. Deliberately quieter than .seatActing — all-in is a standing
+ fact about a player, not a request for the dealer's attention. */
+.seat.seatAllIn {
+ border-color: rgba(255, 207, 107, 0.45);
+}
+
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
the seat next to it. These step the size down so every digit stays on its own
@@ -2504,6 +2534,16 @@ body.hasActionBar #toastHost {
}
@media (max-width: 700px) {
+ /* The narrow layout already had an answer for the bar: it reserves the bar's height
+ as scroll room below the table, and a phone is a thing you scroll. Capping the
+ felt here as well was measured and rejected — at 360x640 it took the table from
+ 321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
+ covered seats traded for two seats whose centre landed on a neighbour. The cap is
+ for the wide layout, which never consulted the bar's height at all. */
+ #pokerTableWrap {
+ max-width: none;
+ }
+
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
.potWizardActions {
flex-direction: column-reverse;
@@ -2519,8 +2559,9 @@ body.hasActionBar #toastHost {
grid-template-columns: 1fr;
}
+ /* One column, so the table has the full width and can afford to be round. */
#pokerTableWrap {
- padding-bottom: 100%;
+ --feltAspect: 1;
}
.seat {
@@ -2845,7 +2886,11 @@ body.hasActionBar #toastHost {
width: 100%;
height: var(--feltRoom);
min-height: 7rem;
- padding-bottom: 0;
+ /* This branch sets its own height outright, so neither the aspect ratio nor the
+ cap derived from it has anything to say here — and the cap would only narrow
+ a table that is already as short as the screen allows. */
+ aspect-ratio: auto;
+ max-width: none;
}
/* The header ate 148px of a 390px-tall window, which was most of the reason the
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 2befb48..eee933b 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -29,6 +29,7 @@ const ActionBar = {
const visible = !host.hidden;
const height = visible ? host.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
+ this.publishFeltRoom(height);
};
if (typeof ResizeObserver === "function") {
@@ -40,6 +41,36 @@ const ActionBar = {
publish();
},
+ // Half a seat, the gap left under the lowest one, and the smallest felt worth
+ // drawing. A seat is about 73px tall at every width — the puck is text-sized, not
+ // table-sized — so the overhang is a constant rather than something read back off
+ // the layout this is about to change. Measuring it would mean shrinking the felt,
+ // moving the seats, and getting a different answer on the next pass.
+ SEAT_OVERHANG_PX: 37,
+ FELT_GAP_PX: 8,
+ FELT_MIN_PX: 220,
+
+ // How tall the felt can be and still leave the whole ring of seats above the bar.
+ //
+ // Publishing the bar's height was only half the job. The wide layout never used it:
+ // the felt sizes itself from its own width — a box 72% as tall as it is wide — and
+ // so has no idea what is underneath it. The bar meanwhile grows with the hand, from
+ // 117px to 172px the moment there's a side pot to show, which is exactly when the
+ // dealer most needs to see who is still in. Measured with two side pots, the bottom
+ // seat sat 22px into the bar at 1280x900, 54px at 1600x1000, 23px at 768x1024.
+ //
+ // The wrap's offset is taken in document space rather than viewport space, so
+ // scrolling the page doesn't resize the table under the dealer's hands.
+ publishFeltRoom(barHeight) {
+ const wrap = document.getElementById("pokerTableWrap");
+ if (!wrap) return;
+ const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
+ const room = window.innerHeight - barHeight - topInDocument -
+ this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
+ document.documentElement.style.setProperty(
+ "--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
+ },
+
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
// still holds during setup, because a seated player is a live seat whether or not
// the betting has been opened. An empty table stays uncluttered.
From a92a38924500f4044ddca9d2c74ffa4a58bd4026 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 12:52:56 -0600
Subject: [PATCH 05/55] Work out who pays whom
I went looking for what home-game dealers actually struggle with rather than reading
the code again, and the answer that kept coming up was the end of the night: hosts
reach for a separate app to finish a game "knowing exactly who owes whom in seconds
rather than minutes".
This tool already had every number that answer needs. The standings panel knows what
each player is up or down -- chips in front of them less the cash they handed over --
and then stops. Which leaves the dealer with six signed numbers at 2am and the actual
job still to do. That last step is the part people get wrong, not the poker.
Ann +$75 Dee +$110 Fay -> Dee $70
Ben -$10 Eve -$40 Cal -> Dee $40
Cal -$65 Fay -$70 Cal -> Ann $25
Eve -> Ann $40
Ben -> Ann $10
Largest debt against largest win, repeatedly. Greedy rather than optimal, which is
the right trade here: the true minimum number of transfers is NP-hard, while this
needs at most one payment fewer than there are people and each one it emits clears
somebody out of the list entirely. A dealer wants a short list to read down, not a
proof. Payers are the outer loop so everything one person owes prints together --
the list gets read out one player at a time.
All of it in integer cents. A settlement that has to come out exactly right is the
last place to let a float land on 41.99999999.
When the chips on the table don't match the cash taken
Then the payments can't all be made, and that is reported rather than quietly spread
across them. It is never rounding -- the arithmetic is integer cents -- so it is
always what the bank check sitting right above it is already warning about: a rebuy
nobody wrote down, or a miscount. Inventing a transfer to make the columns agree is
how a real person ends up short.
$100 of winnings has nothing to pay it -- there are more chips on the table than
cash was taken for. Fix that above before anyone cashes out.
The payment list goes into the CSV export too, under the standings it came from,
with the caveat if there is one. Whoever opens that file wants the answer, not to
redo the arithmetic in a spreadsheet.
13 tests (185 total): everyone ends up square, at most n-1 payments, a payer's lines
stay together, cents survive, nobody pays themselves, no payment is ever for zero,
both directions of an unbalanced bank, and one that settles a real table's standings
rather than hand-made numbers.
Driven through the UI at six viewports, balanced and not: clean layout audit and
contrast everywhere but the landscape phone already known from #63. Checked with
26-character names and seven-figure stacks on a 360px screen -- names wrap, amounts
stay whole and right-aligned, nothing overflows.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 7 +
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 40 ++++++
.../js/casino/holdem/handHistoryExport.js | 21 +++
.../wwwroot/js/casino/holdem/render.js | 52 ++++++++
.../wwwroot/js/casino/holdem/settleUp.js | 66 ++++++++++
tests/holdem/load.js | 2 +
tests/holdem/settleUp.test.js | 124 ++++++++++++++++++
7 files changed, 312 insertions(+)
create mode 100644 JoshHeaps.Net/wwwroot/js/casino/holdem/settleUp.js
create mode 100644 tests/holdem/settleUp.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 7973bb2..982c6e8 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -130,6 +130,12 @@
+
+
Settle up
+
+
+
+
Hands
Resolved pots are logged here, in case a chip count is disputed later.
@@ -443,6 +449,7 @@
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 8fe6162..84c6fec 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1655,6 +1655,46 @@ button,
color: #f0b46a;
}
+/* The settle-up list is read out loud, one line per payment, so it's laid out like
+ the bank rows above it: name on the left, money hard right on tabular figures so a
+ column of amounts lines up on the decimal point. */
+.settleUpList {
+ list-style: none;
+ margin: 0.35rem 0 0;
+ padding: 0;
+}
+
+.settleUpRow {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 0.75rem;
+ font-size: 0.8rem;
+ padding: 0.18rem 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.settleUpRow:last-child {
+ border-bottom: 0;
+}
+
+.settleUpWho {
+ color: #ddd;
+}
+
+.settleUpAmount {
+ color: #f2d489;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+/* Same amber the bank check uses when the columns don't agree — it's the same fault
+ being reported twice, and it should look like it. */
+.fieldHint.settleUpOff {
+ color: #f0b46a;
+}
+
.standingsHeader {
color: #8a8a8a;
font-size: 0.7rem;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
index 9a63925..4a3e4db 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
@@ -53,6 +53,27 @@ const HandHistoryExport = {
Money.forSheet(row.cashPosition || 0),
]);
});
+
+ // And the answer the standings are read to get to. Whoever opens this file
+ // wants the payment list, not to redo the arithmetic in a spreadsheet.
+ const plan = SettleUp.plan(standings);
+ if (plan.payments.length > 0) {
+ rows.push([]);
+ rows.push(["Settle Up"]);
+ rows.push(["Pays", "Receives", "Amount"]);
+ plan.payments.forEach(payment => {
+ rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
+ });
+ if (!plan.balanced) {
+ // The file is the paper trail, so the caveat travels with it rather
+ // than living only in a sidebar nobody exported.
+ rows.push([
+ plan.shortBy > 0
+ ? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
+ : `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
+ ]);
+ }
+ }
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 4410dc7..6d07fc7 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -730,6 +730,58 @@ const Render = {
});
this.renderBankReconciliation();
+ this.renderSettleUp(standings);
+ },
+
+ // The last step the standings stopped short of. Six signed numbers at 2am is not
+ // the answer to "who pays whom" — it's the arithmetic the dealer still has to do,
+ // and it's the part of the night people actually get wrong.
+ renderSettleUp(standings) {
+ const host = document.getElementById("settleUp");
+ if (!host) return;
+
+ const plan = SettleUp.plan(standings);
+ const note = document.getElementById("settleUpNote");
+ const list = document.getElementById("settleUpList");
+
+ // Nothing to show before anyone is up or down. A panel that says "no payments"
+ // to a table that hasn't played a hand is just noise in the sidebar.
+ host.hidden = plan.payments.length === 0 && plan.balanced;
+ if (host.hidden) return;
+
+ list.innerHTML = "";
+ plan.payments.forEach(payment => {
+ const row = document.createElement("li");
+ row.className = "settleUpRow";
+
+ const who = document.createElement("span");
+ who.className = "settleUpWho";
+ // An arrow rather than "pays": the direction is the whole content of the
+ // line, and it survives being read at a glance from across the table.
+ who.textContent = `${payment.from} → ${payment.to}`;
+ row.appendChild(who);
+
+ const amount = document.createElement("span");
+ amount.className = "settleUpAmount";
+ amount.textContent = Money.format(payment.amount);
+ row.appendChild(amount);
+
+ list.appendChild(row);
+ });
+
+ if (plan.balanced) {
+ const count = plan.payments.length;
+ note.textContent = count === 1
+ ? "One payment and the night is square."
+ : `${count} payments and the night is square.`;
+ } else if (plan.shortBy > 0) {
+ note.textContent =
+ `${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
+ } else {
+ note.textContent =
+ `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
+ }
+ note.classList.toggle("settleUpOff", !plan.balanced);
},
// Does the cash box cover the chips on the table? Nothing asked this before, and
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/settleUp.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/settleUp.js
new file mode 100644
index 0000000..f949224
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/settleUp.js
@@ -0,0 +1,66 @@
+// Who hands cash to whom at the end of the night.
+//
+// The standings panel already works out what everyone is up or down. It then stopped,
+// which leaves the dealer with six signed numbers at 2am and the actual job still to
+// do: turning them into payments. That's the step people get wrong — not the poker.
+//
+// Everything is done in cents. A settlement that has to come out exactly right is the
+// last place to let a float land on 41.99999999.
+const SettleUp = {
+ // Largest debt against largest win, repeatedly. It's greedy rather than optimal,
+ // and for this problem that's the right trade: finding the true minimum number of
+ // transfers is NP-hard, while this is guaranteed to need at most one payment fewer
+ // than there are people, and each payment it does emit clears somebody out of the
+ // list entirely. A dealer wants a short list they can read down, not a proof.
+ //
+ // Payers are the outer loop so that everything one person owes is printed together
+ // — the list is read out loud, one player at a time.
+ plan(standings) {
+ const owed = [];
+ const owing = [];
+
+ (standings || []).forEach(row => {
+ const cents = Money.toCents(row.cashPosition);
+ if (cents > 0) owed.push({ name: row.name, cents });
+ else if (cents < 0) owing.push({ name: row.name, cents: -cents });
+ });
+
+ owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
+ owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
+
+ const payments = [];
+ let creditor = 0;
+ owing.forEach(payer => {
+ while (payer.cents > 0 && creditor < owed.length) {
+ const taker = owed[creditor];
+ const amount = Math.min(payer.cents, taker.cents);
+ payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
+ payer.cents -= amount;
+ taker.cents -= amount;
+ if (taker.cents === 0) creditor++;
+ }
+ });
+
+ // What's left over when the chips on the table don't match the cash that came
+ // in. It is never a rounding artifact — the arithmetic is integer cents — so it
+ // is always the thing the bank check is already warning about: a rebuy nobody
+ // wrote down, or a miscount. Reported rather than quietly spread across the
+ // payments, because inventing a transfer to make the columns agree is how a
+ // real person ends up short.
+ const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
+ const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
+
+ return {
+ payments,
+ // Only one of these can be non-zero: either there are more chips than cash
+ // or less, never both.
+ shortBy: unpaid,
+ overBy: unspent,
+ balanced: unpaid === 0 && unspent === 0,
+ };
+ },
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = SettleUp;
+}
diff --git a/tests/holdem/load.js b/tests/holdem/load.js
index b38980f..2e7452a 100644
--- a/tests/holdem/load.js
+++ b/tests/holdem/load.js
@@ -18,6 +18,7 @@ const Money = load("money");
const PotCalculator = load("potCalculator");
const SplitCalculator = load("splitCalculator");
const Betting = load("betting");
+const SettleUp = load("settleUp");
const HandHistoryExport = load("handHistoryExport");
// render.js exports { Render } rather than the namespace directly.
@@ -77,6 +78,7 @@ module.exports = {
HandHistoryExport,
PotCalculator,
SplitCalculator,
+ SettleUp,
Betting,
HoldemState,
makeTable,
diff --git a/tests/holdem/settleUp.test.js b/tests/holdem/settleUp.test.js
new file mode 100644
index 0000000..5bfcc78
--- /dev/null
+++ b/tests/holdem/settleUp.test.js
@@ -0,0 +1,124 @@
+// Turning the standings into "who hands cash to whom".
+const test = require("node:test");
+const assert = require("node:assert");
+const { Money, HoldemState, SettleUp, makeTable } = require("./load");
+
+const standings = pairs => pairs.map(([name, cashPosition]) => ({ name, cashPosition }));
+const lines = plan => plan.payments.map(p => `${p.from} -> ${p.to} ${Money.format(p.amount)}`);
+const paidTo = (plan, name) =>
+ plan.payments.filter(p => p.to === name).reduce((sum, p) => sum + p.amount, 0);
+const paidBy = (plan, name) =>
+ plan.payments.filter(p => p.from === name).reduce((sum, p) => sum + p.amount, 0);
+
+test("one loser, one winner", () => {
+ const plan = SettleUp.plan(standings([["Ann", 40], ["Ben", -40]]));
+ assert.deepEqual(lines(plan), ["Ben -> Ann $40"]);
+ assert.equal(plan.balanced, true);
+});
+
+test("everybody flat means nothing to do", () => {
+ const plan = SettleUp.plan(standings([["Ann", 0], ["Ben", 0]]));
+ assert.deepEqual(plan.payments, []);
+ assert.equal(plan.balanced, true);
+});
+
+test("nobody at all means nothing to do", () => {
+ const plan = SettleUp.plan([]);
+ assert.deepEqual(plan.payments, []);
+ assert.equal(plan.balanced, true);
+});
+
+test("each player ends up square", () => {
+ const table = standings([
+ ["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
+ ]);
+ const plan = SettleUp.plan(table);
+
+ table.forEach(row => {
+ const settled = paidTo(plan, row.name) - paidBy(plan, row.name);
+ assert.equal(settled, row.cashPosition, `${row.name} should end up square`);
+ });
+ assert.equal(plan.balanced, true);
+});
+
+test("at most one payment fewer than there are people", () => {
+ const table = standings([
+ ["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
+ ]);
+ const plan = SettleUp.plan(table);
+ assert.ok(plan.payments.length <= table.length - 1,
+ `${plan.payments.length} payments for ${table.length} players`);
+});
+
+test("a player who owes two people has both lines together", () => {
+ // The list gets read out one player at a time, so a payer's obligations must not
+ // be split up by somebody else's.
+ const plan = SettleUp.plan(standings([["Ann", 50], ["Cal", 30], ["Ben", -80]]));
+ assert.deepEqual(lines(plan), ["Ben -> Ann $50", "Ben -> Cal $30"]);
+});
+
+test("cents survive", () => {
+ const plan = SettleUp.plan(standings([["Ann", 0.1], ["Ben", 0.2], ["Cal", -0.3]]));
+ assert.deepEqual(lines(plan), ["Cal -> Ben $0.20", "Cal -> Ann $0.10"]);
+ assert.equal(plan.balanced, true);
+});
+
+test("more chips than cash leaves somebody unpaid, and says so", () => {
+ // The table is $25 up on the bank — an unrecorded rebuy or a miscount. The winners
+ // cannot all be paid, and pretending otherwise is how a real person ends up short.
+ const plan = SettleUp.plan(standings([["Ann", 100], ["Ben", -75]]));
+ assert.deepEqual(lines(plan), ["Ben -> Ann $75"]);
+ assert.equal(plan.shortBy, 25);
+ assert.equal(plan.overBy, 0);
+ assert.equal(plan.balanced, false);
+});
+
+test("less chips than cash leaves a payer with nowhere to send it", () => {
+ const plan = SettleUp.plan(standings([["Ann", 50], ["Ben", -75]]));
+ assert.deepEqual(lines(plan), ["Ben -> Ann $50"]);
+ assert.equal(plan.overBy, 25);
+ assert.equal(plan.shortBy, 0);
+ assert.equal(plan.balanced, false);
+});
+
+test("no payment is ever for nothing", () => {
+ const plan = SettleUp.plan(standings([
+ ["Ann", 60], ["Ben", -60], ["Cal", 0], ["Dee", 0],
+ ]));
+ assert.ok(plan.payments.every(p => p.amount > 0));
+ assert.equal(plan.payments.length, 1);
+});
+
+test("nobody pays themselves", () => {
+ const plan = SettleUp.plan(standings([
+ ["Ann", 90], ["Ben", -30], ["Cal", -30], ["Dee", -30],
+ ]));
+ assert.ok(plan.payments.every(p => p.from !== p.to));
+});
+
+test("the payments total what the winners are owed", () => {
+ const table = standings([["Ann", 42.5], ["Ben", 17.5], ["Cal", -60]]);
+ const plan = SettleUp.plan(table);
+ const moved = plan.payments.reduce((sum, p) => sum + p.amount, 0);
+ assert.equal(Money.round(moved), 60);
+});
+
+test("it settles a real table's standings, not just hand-made numbers", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[0].buyIns = 100;
+ state.seats[1].buyIns = 100;
+ state.seats[2].buyIns = 100;
+ state.seats[0].stack = 175;
+ state.seats[1].stack = 90;
+ state.seats[2].stack = 35;
+
+ const rows = HoldemState.sessionStandings(state);
+ const plan = SettleUp.plan(rows);
+
+ // Bank balances — 300 in, 300 on the table — so everyone can be paid.
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ assert.equal(plan.balanced, true);
+ rows.forEach(row => {
+ assert.equal(paidTo(plan, row.name) - paidBy(plan, row.name), row.cashPosition);
+ });
+});
From 2cc2c66976a2e60fbc7e28518259d42090693c7d Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 13:08:21 -0600
Subject: [PATCH 06/55] Deal around a player who busted and hasn't rebought
The most ordinary thing that happens in a home game: somebody stacks off, and they
don't buy back in that second. The table dealt them in anyway, and the result was a
hand nobody could finish.
Three players, two of them busted:
bar before dealing:
"Pot $0 | 3 players | All-in - running out the board (3 players)
| Cal posts $1, Ann posts $2 | Post blinds & deal"
after dealing:
Cal posted nothing -- no chips to post with
Ann posted $2 and was asked to check or bet against her own money
liveSeats 3, isStreetComplete false, isHandOver false
Every line of that is wrong. It announced a board being run out over a felt with no
cards on it, promised a small blind from a seat that couldn't post one, took it from
nobody, and then sat on a hand with no way forward.
So a seat with no chips and nothing in the pot is dealt around. Their seat, name and
buy-in record are untouched -- sat out, not folded, because they didn't muck anything
-- and adding chips puts them straight back in the next hand.
four players, Ben busted, button on Ann:
"3 players | Cal posts $1, Dee posts $2
| Ben has no chips - sitting out this hand. | Post blinds & deal"
-> Ben inHand false, folded false; blinds exactly as promised; Ann to act
Said before it happens, for the same reason the button move is: a dealer shouldn't
find out from the felt. And the preview computes it on a view of the table rather
than by changing it, so the promise and the deal ask the same question -- the fault
these two lines exist to prevent.
When it can't be dealt at all, it says who has to buy in rather than pretending it's
a seating problem:
"Only Ann has chips - Ben and Cal need to buy in."
"Nobody at the table has chips left. Add a buy-in to deal."
Two smaller things fall out. The run-out banner is now gated on the betting actually
being open -- before the deal, a table of busted players is indistinguishable from a
table of all-ins to that test. And the bar's player count reads the table as it will
be dealt, so someone waiting on a rebuy isn't announced as one of the players.
Not the same as all-in, and the code says so: an all-in player has money riding on
the hand. A seat with no stack recorded isn't broke either -- those fall back to the
table's starting stack.
13 tests (198 total), and 5 of them fail with the sit-out and the run-out guard taken
back out.
Driven through the UI at seven viewports in both states: layout and contrast clean
everywhere but the landscape phone already known from #63, and no console errors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/actionBar.js | 41 ++++-
.../wwwroot/js/casino/holdem/betting.js | 66 +++++++-
tests/holdem/brokeSeats.test.js | 148 ++++++++++++++++++
3 files changed, 243 insertions(+), 12 deletions(-)
create mode 100644 tests/holdem/brokeSeats.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index eee933b..8abed50 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -129,8 +129,12 @@ const ActionBar = {
row.appendChild(pot);
// How many are still in the hand — part of the standard per-street
- // announcement, and the number a dealer is asked for most often.
- const remaining = Betting.liveSeats(this.state).length;
+ // announcement, and the number a dealer is asked for most often. Before the
+ // deal it counts the table as it will be dealt, so a busted player waiting on a
+ // rebuy isn't announced as one of the players. Read-only: the sitting-out
+ // itself happens when the hand is dealt, not while the bar is being drawn.
+ const counted = this.state.bettingOpen ? this.state : Betting.tableAsDealt(this.state);
+ const remaining = Betting.liveSeats(counted).length;
const players = document.createElement("span");
players.className = "actionPlayers";
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
@@ -503,22 +507,35 @@ const ActionBar = {
const preview = Betting.blindPreview(this.state);
const seated = Betting.liveSeats(this.state).length;
+ const broke = Betting.brokeSeats(this.state);
+ const name = i => HoldemState.displayName(this.state.seats[i], i);
+ const listNames = indexes => indexes.length === 1
+ ? name(indexes[0])
+ : `${indexes.slice(0, -1).map(name).join(", ")} and ${name(indexes[indexes.length - 1])}`;
const line = document.createElement("p");
line.className = "dealPromptLine";
if (!preview) {
// Two players is the least you can deal. Say what's missing rather than
- // offering a button that would quietly do nothing.
- line.textContent = seated === 0
- ? "Nobody is dealt in yet. Tap a seat to add a player."
- : "Only one player is dealt in — a hand needs two.";
+ // offering a button that would quietly do nothing — and say the *right*
+ // thing: "only one player is dealt in" is wrong when four are sitting there
+ // and three of them are just out of chips.
+ const withChips = seated - broke.length;
+ if (broke.length > 0 && withChips < 2) {
+ line.textContent = withChips === 1
+ ? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
+ : "Nobody at the table has chips left. Add a buy-in to deal.";
+ } else {
+ line.textContent = seated === 0
+ ? "Nobody is dealt in yet. Tap a seat to add a player."
+ : "Only one player is dealt in — a hand needs two.";
+ }
line.classList.add("dealPromptBlocked");
wrap.appendChild(line);
return wrap;
}
- const name = i => HoldemState.displayName(this.state.seats[i], i);
line.textContent =
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
@@ -536,6 +553,16 @@ const ActionBar = {
wrap.appendChild(moved);
}
+ // Dealing sits out anyone with nothing behind. Said before it happens, for the
+ // same reason the button move is: a dealer shouldn't find out from the felt.
+ if (preview.satOut.length > 0) {
+ const out = document.createElement("p");
+ out.className = "dealPromptLine dealPromptAside";
+ out.textContent = `${listNames(preview.satOut)} ${preview.satOut.length === 1 ? "has" : "have"} no chips — ` +
+ `${preview.satOut.length === 1 ? "sitting" : "they sit"} out this hand.`;
+ wrap.appendChild(out);
+ }
+
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 8302997..c3e4494 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -32,23 +32,59 @@ const Betting = {
// Who would post if the hand were dealt right now. Used to preview the blinds
// while the table is still being set, so a dealer can see where they'll land
// before any money moves.
+ // A seat that is dealt in, has nothing in the pot, and has no chips behind.
+ //
+ // Not the same as all-in: an all-in player has money riding on the hand. This is
+ // somebody who busted and hasn't bought back in yet, which a home game produces
+ // about once an hour. A seat with no stack *recorded* isn't broke either —
+ // stackOf falls back to the table's starting stack for those — only an explicit
+ // zero counts.
+ brokeSeats(state) {
+ const broke = [];
+ state.seats.forEach((player, index) => {
+ if (!player.inHand) return;
+ if (this.handTotal(player) > this.EPSILON_CHIPS) return;
+ if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
+ broke.push(index);
+ });
+ return broke;
+ },
+
+ // The table as it will be once the seats that can't post are dealt around. A view,
+ // not a change: the preview has to ask the same question the deal will answer, and
+ // asking it of a different shape of table is how the two came apart last time.
+ tableAsDealt(state) {
+ const broke = this.brokeSeats(state);
+ if (broke.length === 0) return state;
+ const skip = new Set(broke);
+ return {
+ ...state,
+ seats: state.seats.map((player, index) =>
+ skip.has(index) ? { ...player, inHand: false, folded: false } : player),
+ };
+ },
+
blindPreview(state) {
// Dealing relocates a button parked on an empty chair before it works out who
// posts, so the preview has to answer for the button where it *will* be. Asking
// from where it currently sits gave a different pair of blinds than the deal
// produced — the preview promising one thing and doing another is exactly the
- // guessing this whole step exists to remove.
- const buttonIndex = HoldemState.buttonSeatForDeal(state);
- const asDealt = buttonIndex === state.dealerSeatIndex
- ? state
- : { ...state, dealerSeatIndex: buttonIndex };
+ // guessing this whole step exists to remove. Same reason the broke seats come
+ // out first: the preview once promised a small blind from a seat with no chips.
+ const dealt = this.tableAsDealt(state);
+ const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
+ const asDealt = buttonIndex === dealt.dealerSeatIndex
+ ? dealt
+ : { ...dealt, dealerSeatIndex: buttonIndex };
+ const satOut = this.brokeSeats(state);
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
if (sbIndex === null || bbIndex === null) return null;
return {
sbIndex, bbIndex, headsUp, manual: !!manual,
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
+ satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
};
@@ -303,6 +339,11 @@ const Betting = {
// True when the hand is still live but nobody can bet again — the dealer is just
// running the board out.
isRunningOut(state) {
+ // Only while a hand is actually being played. Before the deal, a table of
+ // busted players who haven't rebought looks exactly like a table of all-ins to
+ // this test, and the bar was announcing a run-out over a felt with no cards and
+ // no chips on it.
+ if (!state.bettingOpen) return false;
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
},
@@ -340,6 +381,21 @@ const Betting = {
state.currentBet = 0;
state.lastRaiseSize = 0;
+ // Deal around anyone with no chips, before anything else reads the table.
+ //
+ // A busted player who hasn't rebought used to be dealt in anyway, and the
+ // result was a hand nobody could finish: the table promised a small blind from
+ // a seat with nothing to post it with, took it from nobody, put the action on
+ // the big blind with only their own money in front of them, and announced
+ // "3 players — all-in, running out the board" before a card was dealt.
+ //
+ // Sitting them out is what a dealer does. Seat, name and buy-in record are
+ // untouched, so dealing them back in is one tap when they buy chips.
+ this.brokeSeats(state).forEach(index => {
+ state.seats[index].inHand = false;
+ state.seats[index].folded = false;
+ });
+
// Position is read off the button all through the hand, so it has to be on
// somebody before the blinds are worked out — not on the empty seat 1 a
// brand-new table starts on.
diff --git a/tests/holdem/brokeSeats.test.js b/tests/holdem/brokeSeats.test.js
new file mode 100644
index 0000000..60c91fd
--- /dev/null
+++ b/tests/holdem/brokeSeats.test.js
@@ -0,0 +1,148 @@
+// Dealing around a player who busted and hasn't bought back in.
+//
+// A home game produces one of these about once an hour. The table used to deal them
+// in anyway, promise a blind from a seat with nothing to post it with, take it from
+// nobody, and then sit on a hand that could never finish.
+const test = require("node:test");
+const assert = require("node:assert");
+const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
+
+const postedBy = state =>
+ state.seats
+ .map((player, index) => (player.committed > 0 ? { seat: index + 1, amount: player.committed } : null))
+ .filter(Boolean);
+
+test("a seat with no chips and nothing in the pot is broke", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[2].stack = 0;
+ assert.deepEqual(Betting.brokeSeats(state), [2]);
+});
+
+test("an all-in player is not broke — they have money riding on the hand", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[2].stack = 12;
+ Betting.beginHand(state);
+ Betting.allIn(state, 2);
+
+ assert.equal(Betting.stackOf(state, state.seats[2]), 0);
+ assert.equal(Betting.isAllIn(state, state.seats[2]), true);
+ assert.deepEqual(Betting.brokeSeats(state), []);
+});
+
+test("a seat with no stack recorded falls back to the table's and isn't broke", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.defaultStack = 200;
+ state.seats[2].stack = null;
+ assert.deepEqual(Betting.brokeSeats(state), []);
+});
+
+test("a chair nobody is sitting in isn't broke, it's empty", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ assert.deepEqual(Betting.brokeSeats(state), []);
+});
+
+test("dealing sits the broke seat out and leaves everything else about it alone", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
+ state.seats[2].stack = 0;
+ state.seats[2].name = "Cal";
+ state.seats[2].buyIns = 100;
+
+ Betting.beginHand(state);
+
+ assert.equal(state.seats[2].inHand, false);
+ assert.equal(state.seats[2].folded, false, "sat out, not folded — they didn't muck anything");
+ assert.equal(state.seats[2].name, "Cal");
+ assert.equal(state.seats[2].buyIns, 100);
+});
+
+test("the blinds skip the broke seat and land on players who can post", () => {
+ // Button on seat 1, so the blinds would be seats 2 and 3. Seat 2 is busted.
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
+ state.seats[1].stack = 0;
+
+ Betting.beginHand(state);
+
+ assert.deepEqual(postedBy(state), [
+ { seat: 3, amount: state.smallBlind },
+ { seat: 4, amount: state.bigBlind },
+ ]);
+});
+
+test("the preview promises what the deal produces", () => {
+ // The fault this whole step exists to prevent: the preview once read a table the
+ // deal was about to change underneath it.
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
+ state.seats[1].stack = 0;
+ state.bettingOpen = false;
+
+ const preview = Betting.blindPreview(state);
+ assert.deepEqual(preview.satOut, [1]);
+
+ Betting.beginHand(state);
+ const posted = postedBy(state);
+ assert.equal(posted.find(p => p.amount === state.smallBlind).seat, preview.sbIndex + 1);
+ assert.equal(posted.find(p => p.amount === state.bigBlind).seat, preview.bbIndex + 1);
+});
+
+test("asking for the preview doesn't sit anybody out on its own", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[1].stack = 0;
+
+ Betting.blindPreview(state);
+
+ assert.equal(state.seats[1].inHand, true, "the preview is a view, not a change");
+});
+
+test("with only one player left holding chips there is nothing to preview", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[1].stack = 0;
+ state.seats[2].stack = 0;
+
+ assert.equal(Betting.blindPreview(state), null);
+});
+
+test("no chips move when the broke seats are dealt around", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
+ state.seats[2].stack = 0;
+ const before = chipsOnTable(state);
+
+ Betting.beginHand(state);
+
+ assert.equal(chipsOnTable(state), before);
+});
+
+test("the board isn't running out before the hand is dealt", () => {
+ // Two busted players and one with chips looks exactly like a table of all-ins to
+ // the run-out test, and the bar was announcing one over an empty felt.
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[1].stack = 0;
+ state.seats[2].stack = 0;
+ state.bettingOpen = false;
+
+ assert.equal(Betting.isRunningOut(state), false);
+});
+
+test("buying back in puts them in the next hand", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[2].stack = 0;
+ Betting.beginHand(state);
+ assert.equal(state.seats[2].inHand, false);
+
+ // What the dealer does when they hand over cash: chips, and back in.
+ state.seats[2].stack = 100;
+ state.seats[2].inHand = true;
+ HoldemState.startNextHand(state);
+ Betting.beginHand(state);
+
+ assert.equal(state.seats[2].inHand, true);
+ assert.equal(Betting.liveSeats(state).length, 3);
+});
+
+test("the table dealt around them counts the right number of players", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
+ state.seats[3].stack = 0;
+
+ assert.equal(Betting.liveSeats(state).length, 4);
+ assert.equal(Betting.liveSeats(Betting.tableAsDealt(state)).length, 3);
+ assert.equal(state.seats[3].inHand, true, "the view didn't touch the table");
+});
From 1af82dfddf6af39fa83a80d45f3a6ced29ebcca8 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 13:23:02 -0600
Subject: [PATCH 07/55] Call a bet a bet and a raise a raise
The bar decided which word to use from what the acting player still owes. That gets
the big blind's option backwards -- they owe nothing, so it offered "Bet", but their
own blind is a live bet and putting more in is a raise. And it gets the other half
wrong too: open the pad to make a first bet on the flop and the confirm button said
"Raise to", then wrote "Ann's raise to $10" into the undo control for it.
preflop, action back to the big blind
was: Fold | Check | Bet pad: "Raise to"
now: Fold | Check | Raise pad: "Raise to"
flop, nobody has bet
was: Fold | Check | Bet pad: "Raise to" undo: "Ann's raise to $10"
now: Fold | Check | Bet pad: "Bet" undo: "Ann's bet of $10"
The arithmetic was always right -- minRaiseTo returns two big blinds for the big
blind's option and one for an opening bet, and I checked both before touching
anything. This is only what it is called. For a tool a dealer announces the action
from, that is not a small thing: a dealer who says "bet" when the big blind puts more
in gets corrected by the table.
The test is whether there is a live bet to raise, not whether this player owes
anything, and it is one helper rather than a condition written out five times.
The warnings follow the same word. Under-minimum with a bet in front of you leaves
you calling it; with an empty pot there is nothing to call:
"$3 is under the $4 minimum. A legal raise is $4 or more -- otherwise it's a
call, or all-in."
"$1 is under the $2 minimum. A legal bet is $2 or more -- otherwise it's a
check, or all-in."
Also the field's screen-reader label, which announced "Raise to amount" for a bet.
8 tests (206 total), including a table playing without blinds -- which opens with a
bet, and is the one preflop case where the old condition happened to be right.
Driven through the UI at seven viewports in both spots: every label correct, contrast
clean, no console errors.
One thing found and deliberately not touched. Fuzzing 40 hands with random actions
and rebuys -- chips stayed conserved to the cent throughout, no negative stacks, and
the acting seat was always a seat that could act -- turned up one disagreement:
isStreetComplete returns false during an all-in run-out where one player still has
chips and hasn't acted, while actingSeatIndex is correctly null. It has no symptom,
because the bar picks its layout from actingSeatIndex and shows "Deal Turn" as it
should. Changing a betting-round predicate with no observable fault behind it is a
worse trade than leaving it written down.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/actionBar.js | 30 +++++--
.../wwwroot/js/casino/holdem/betting.js | 16 ++++
tests/holdem/wagerVerb.test.js | 83 +++++++++++++++++++
3 files changed, 121 insertions(+), 8 deletions(-)
create mode 100644 tests/holdem/wagerVerb.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 8abed50..647c051 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -345,7 +345,10 @@ const ActionBar = {
}
if (stack > owed) {
- row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
+ // Not "does this player owe anything" — the big blind owes nothing and is
+ // still raising. It's whether there is a live bet to raise.
+ const raising = Betting.wagerVerb(this.state) === "raise";
+ row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => {
this.raiseOpen = true;
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
this.render();
@@ -378,6 +381,7 @@ const ActionBar = {
// or a player whose whole stack is under a full raise never gets told that.
const legalMin = Betting.minRaiseTo(this.state);
const minTo = Math.min(legalMin, maxTo);
+ const raising = Betting.wagerVerb(this.state) === "raise";
const wrap = document.createElement("div");
wrap.className = "raisePad";
@@ -410,7 +414,7 @@ const ActionBar = {
input.inputMode = "decimal";
input.className = "raiseInput";
input.value = this.raiseDraft ?? minTo;
- input.setAttribute("aria-label", "Raise to amount");
+ input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount");
input.addEventListener("input", () => {
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
this.updateRaiseNote(wrap, legalMin, maxTo);
@@ -422,11 +426,16 @@ const ActionBar = {
this.render();
}));
- entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
+ // "Raise to $60" and "Bet $10" — a bet isn't *to* anything, there's nothing
+ // under it to raise past.
+ entry.appendChild(this.button(raising ? "Raise to" : "Bet", "raiseConfirm", () => {
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
this.raiseOpen = false;
const who = HoldemState.displayName(player, index);
- this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target));
+ const label = raising
+ ? `${who}'s raise to ${Money.format(target)}`
+ : `${who}'s bet of ${Money.format(target)}`;
+ this.commit(label, () => Betting.raiseTo(this.state, index, target));
}));
wrap.appendChild(entry);
@@ -455,6 +464,7 @@ const ActionBar = {
const typed = Number(this.raiseDraft);
const current = this.state.currentBet || 0;
+ const raising = Betting.wagerVerb(this.state) === "raise";
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
@@ -463,16 +473,20 @@ const ActionBar = {
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
- note.textContent =
- `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`;
+ // The way out of an under-minimum differs: with a bet in front of you the
+ // alternative is calling it, and with an empty pot there's nothing to call.
+ note.textContent = raising
+ ? `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`
+ : `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal bet is ${Money.format(minTo)} or more — otherwise it's a check, or all-in.`;
note.className = "raiseNote raiseNoteWarn";
note.hidden = false;
return;
}
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
- note.textContent =
- `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`;
+ note.textContent = raising
+ ? `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`
+ : `All-in for less than a full bet. Legal, but it doesn't reopen the betting — anyone who has already checked can call it or fold, not raise.`;
note.className = "raiseNote raiseNoteInfo";
note.hidden = false;
return;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index c3e4494..f445e2f 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -90,6 +90,22 @@ const Betting = {
};
},
+ // "bet" or "raise" — what putting more in is actually called right now.
+ //
+ // A wager into a pot nobody has wagered into is a bet; everything after that is a
+ // raise. The bar decided this from what the acting player still owes, which gets
+ // the big blind's option backwards: they owe nothing, so it offered "Bet", but
+ // their own blind is a live bet and putting more in is a raise. It gets the other
+ // half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
+ // and wrote "Ann's raise to $10" into the undo control for it.
+ //
+ // The arithmetic was always right (minRaiseTo returns two big blinds for the big
+ // blind's option and one for an opening bet). This is only what it's called, which
+ // for a tool a dealer announces the action from is not a small thing.
+ wagerVerb(state) {
+ return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
+ },
+
committed(player) {
return this.round(player.committed || 0);
},
diff --git a/tests/holdem/wagerVerb.test.js b/tests/holdem/wagerVerb.test.js
new file mode 100644
index 0000000..20604d5
--- /dev/null
+++ b/tests/holdem/wagerVerb.test.js
@@ -0,0 +1,83 @@
+// Whether putting more in is called a bet or a raise.
+//
+// The arithmetic was always right — minRaiseTo returns two big blinds for the big
+// blind's option and one for an opening bet. This is only what it's called, which for
+// a tool a dealer announces the action from is not a small thing.
+const test = require("node:test");
+const assert = require("node:assert");
+const { Betting, makeTable } = require("./load");
+
+// Walks the preflop action round to the big blind, who is owed nothing and gets to
+// choose between checking and putting more in.
+function toTheBigBlindsOption() {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
+ Betting.beginHand(state);
+ const bigBlind = state.seats.findIndex(player => Betting.committed(player) === state.bigBlind);
+ while (state.actingSeatIndex !== null && state.actingSeatIndex !== bigBlind) {
+ Betting.call(state, state.actingSeatIndex);
+ }
+ return { state, bigBlind };
+}
+
+test("the big blind's option is a raise, not a bet", () => {
+ const { state, bigBlind } = toTheBigBlindsOption();
+
+ assert.equal(state.actingSeatIndex, bigBlind);
+ assert.equal(Betting.toCall(state, state.seats[bigBlind]), 0, "they owe nothing");
+ assert.equal(Betting.wagerVerb(state), "raise", "but their own blind is a live bet");
+});
+
+test("and the minimum there is a real raise, not a call of the blind", () => {
+ const { state } = toTheBigBlindsOption();
+ assert.equal(Betting.minRaiseTo(state), state.bigBlind * 2);
+});
+
+test("facing the blinds preflop is a raise", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
+ Betting.beginHand(state);
+ assert.equal(Betting.wagerVerb(state), "raise");
+});
+
+test("first in on the flop is a bet", () => {
+ const { state, bigBlind } = toTheBigBlindsOption();
+ Betting.check(state, bigBlind);
+ Betting.closeStreet(state);
+
+ assert.equal(state.street, "flop");
+ assert.equal(Betting.wagerVerb(state), "bet");
+ assert.equal(Betting.minRaiseTo(state), state.bigBlind, "a first bet is one big blind");
+});
+
+test("after that bet it's a raise again", () => {
+ const { state, bigBlind } = toTheBigBlindsOption();
+ Betting.check(state, bigBlind);
+ Betting.closeStreet(state);
+ Betting.raiseTo(state, state.actingSeatIndex, 10);
+
+ assert.equal(Betting.wagerVerb(state), "raise");
+});
+
+test("and a bet again on the next street once it's checked around", () => {
+ const { state, bigBlind } = toTheBigBlindsOption();
+ Betting.check(state, bigBlind);
+ Betting.closeStreet(state);
+ while (state.actingSeatIndex !== null) Betting.check(state, state.actingSeatIndex);
+ Betting.closeStreet(state);
+
+ assert.equal(state.street, "turn");
+ assert.equal(Betting.wagerVerb(state), "bet");
+});
+
+test("a table playing without blinds opens with a bet", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, smallBlind: 0, bigBlind: 0 });
+ Betting.beginHand(state);
+ assert.equal(state.currentBet, 0);
+ assert.equal(Betting.wagerVerb(state), "bet");
+});
+
+test("asking doesn't change anything", () => {
+ const { state } = toTheBigBlindsOption();
+ const before = JSON.stringify(state);
+ Betting.wagerVerb(state);
+ assert.equal(JSON.stringify(state), before);
+});
From 96cd7684f00b34fa69a7d9c4fd477cd42b25acba Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 13:39:56 -0600
Subject: [PATCH 08/55] Buying chips puts you back in the game
Handing over cash and being dealt in are one event at a real table. Here they were
two, and the second one was easy to miss.
Last change deals a busted player around until they rebuy. So the dealer takes their
money, taps Add chips, and:
Ben: inHand false -> false stack 0 -> 100 buyIns 100 -> 200
toast: "Ben added $100."
He has chips, he is not in the game, and nothing on screen says so. He gets skipped
hand after hand until somebody notices and finds "Deal in" in the seat menu.
Now the buy-in does both, and says which:
mid-hand: "Ben added $100 - in from the next hand." inHand true, folded true
between hands: "Ben added $100 - dealt in." inHand true, folded false
Folding him for the hand in progress is the rule "Deal in" already follows -- you
can't buy into a pot you have not been paying for -- so the two doors into the game
behave the same way. It is one undoable step: undo takes back the chips, the cash
record and the seat.
Not for a seat that was sat out while holding chips. That is a dealer who sat someone
out on purpose and is topping them up, and dealing them back in would be the tool
guessing at something it was not told. The test reads the seat's own recorded stack
rather than stackOf(), which substitutes the table's starting stack and would make a
seat with nothing recorded look like it was holding chips.
busted, sat out -> back in
new chair, no stack yet -> in
sat out holding $100 -> stays out
already in the hand -> nothing to do, including an all-in at zero chips
8 tests (214 total).
Driven through the UI: the rebuy, the toast, the next hand actually dealing him in
(4 players, live 4), and undo putting all three fields back.
Found on the way, not fixed here
Buying in on a seat with no stack of its own credits the table's starting stack *plus*
the buy-in. A new player handed $100 on a table with a $100 default ends up with $200
of chips against $100 of recorded cash.
I am leaving it because the right answer is genuinely unclear and it is not mine to
pick. A blank stack means "no chips recorded" to the bank check, which counts it as
zero, and "the table's starting stack" to the betting code and the felt, which show
it as $100. Both are defensible for their own job, and which one a buy-in should
follow decides whether a casual dealer who never types stacks gets $200 or $100 after
a rebuy. Worth saying out loud rather than quietly picking one.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/betting.js | 22 ++++++
.../wwwroot/js/casino/holdem/main.js | 22 +++++-
tests/holdem/buyIn.test.js | 76 +++++++++++++++++++
3 files changed, 117 insertions(+), 3 deletions(-)
create mode 100644 tests/holdem/buyIn.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index f445e2f..0bf6856 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -90,6 +90,28 @@ const Betting = {
};
},
+ // Does handing this seat chips also put them back in the game?
+ //
+ // At a real table those are one event, and here they were two: a busted player got
+ // their chips and stayed sat out, silently skipped hand after hand until somebody
+ // noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
+ // read "Ben added $100".
+ //
+ // Not for a seat that was sat out while *holding* chips. That's a dealer who sat
+ // somebody out on purpose and is topping them up, and dealing them back in would be
+ // the tool guessing at something it wasn't told. Only a seat with no stack of its
+ // own: busted at zero, or a chair being bought into for the first time.
+ //
+ // Deliberately reads player.stack rather than stackOf(), which substitutes the
+ // table's starting stack for a seat that has none — the question here is what this
+ // seat was actually recorded as holding.
+ buyInDealsThemIn(player) {
+ if (!player || player.inHand) return false;
+ const own = player.stack;
+ const recorded = own !== null && own !== undefined && String(own).trim() !== "";
+ return !recorded || Number(own) <= this.EPSILON_CHIPS;
+ },
+
// "bet" or "raise" — what putting more in is actually called right now.
//
// A wager into a pot nobody has wagered into is a bet; everything after that is a
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 00fd555..0781bc0 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -140,11 +140,27 @@ document.addEventListener("DOMContentLoaded", () => {
editSeat: index => Render.toggleSeatEditor(index),
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
- seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
+ // Buying chips puts you back in the game — see Betting.buyInDealsThemIn.
+ const dealBackIn = Betting.buyInDealsThemIn(state.seats[seatIndex]);
+ const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
+
+ const message = `${nameOf(seatIndex)} added ${Money.format(amount)}` +
+ (dealBackIn
+ ? handRunning ? " — in from the next hand." : " — dealt in."
+ : ".");
+
+ seatAction(seatIndex, message, p => {
// Chips in front and cash in are different questions — the stack moves
// every hand, what they've handed over only moves when they buy.
- player.stack = Money.round(Betting.stackOf(state, player) + amount);
- player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
+ p.stack = Money.round(Betting.stackOf(state, p) + amount);
+ p.buyIns = Money.round((Number(p.buyIns) || 0) + amount);
+
+ if (dealBackIn) {
+ p.inHand = true;
+ // Same rule "Deal in" follows: you can't buy into a pot you haven't
+ // been paying for, and `folded` is exactly that state for one hand.
+ p.folded = handRunning;
+ }
});
}),
};
diff --git a/tests/holdem/buyIn.test.js b/tests/holdem/buyIn.test.js
new file mode 100644
index 0000000..40cb015
--- /dev/null
+++ b/tests/holdem/buyIn.test.js
@@ -0,0 +1,76 @@
+// Whether handing a seat chips also puts them back in the game.
+const test = require("node:test");
+const assert = require("node:assert");
+const { Betting, makeTable } = require("./load");
+
+test("a busted player who has been sat out comes back in", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[1].stack = 0;
+ Betting.beginHand(state); // the deal sits them out
+
+ assert.equal(state.seats[1].inHand, false);
+ assert.equal(Betting.buyInDealsThemIn(state.seats[1]), true);
+});
+
+test("a chair being bought into for the first time comes in", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+ state.seats[4].name = "Eve";
+ state.seats[4].stack = null;
+
+ assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
+});
+
+test("a seat sat out while holding chips stays out", () => {
+ // A dealer sat them out on purpose and is topping them up. Dealing them back in
+ // would be the tool guessing at something it wasn't told.
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[2].inHand = false;
+
+ assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
+});
+
+test("somebody already in the hand is left exactly where they are", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ assert.equal(Betting.buyInDealsThemIn(state.seats[0]), false);
+
+ // Including a player who is in but out of chips — they're all-in, not sat out.
+ state.seats[2].stack = 0;
+ assert.equal(state.seats[2].inHand, true);
+ assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
+});
+
+test("a blank stack field reads as nothing recorded, not as a number", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+ state.seats[3].name = "Dee";
+ state.seats[3].stack = "";
+
+ assert.equal(Betting.buyInDealsThemIn(state.seats[3]), true);
+});
+
+test("it doesn't ask the table what a missing stack would have been", () => {
+ // stackOf substitutes the table's starting stack for a seat that has none, which
+ // would make a seat with nothing recorded look like it was holding chips.
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+ state.defaultStack = 500;
+ state.seats[4].name = "Eve";
+ state.seats[4].stack = null;
+
+ assert.equal(Betting.stackOf(state, state.seats[4]), 500);
+ assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
+});
+
+test("asking doesn't change anything", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[2].stack = 0;
+ state.seats[2].inHand = false;
+ const before = JSON.stringify(state);
+
+ Betting.buyInDealsThemIn(state.seats[2]);
+
+ assert.equal(JSON.stringify(state), before);
+});
+
+test("nothing to decide about a seat that isn't there", () => {
+ assert.equal(Betting.buyInDealsThemIn(null), false);
+ assert.equal(Betting.buyInDealsThemIn(undefined), false);
+});
From 3e9e2e899d6d72579d3e4eb8e69e2da19a4caa89 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 14:00:34 -0600
Subject: [PATCH 09/55] Stop the big blind's own chip from covering the BB
badge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On a phone the badge marking the big blind was not hard to read. It was not there.
The chip showing their $2 sat on top of it.
Both live on the same spoke. Bets sit between a seat and the pot, so on the bottom
half of the ring the chip comes down onto the seat's top edge, which is exactly where
the badge was -- and the chip is a layer above it.
390x844, six seats: badge 24x12 = 288px², chip covered 196px² of it
badge z-index 2, chip z-index 3
the badge's own centre hit-tested as the chip
So seats on the bottom of the ring wear their badge underneath instead, on the edge
the pot is not on. And badges now outrank chips regardless, so a layout this does not
anticipate cannot hide one again: which blind a seat is posting is the one thing on
that puck nothing else on the felt repeats, while a bet chip's amount is on the seat's
own total line as well.
Measured across 20 table sizes -- badge area lost to bet chips:
430x932, 390x844, 360x640 at 2 and 6 seats 35% -> 0%
8 seats 4-12%, unchanged: the blinds land
on the top arc there, where the
badge was already out of the way
desktop and tablet 0% either way, more room
Two other placements were measured and rejected. Tucking the badge inside the seat's
bottom edge put 38-42% of it on the seat's own stack line at every size including
desktop -- trading a chip covering the badge for the badge covering the money. Leaving
it on top and only raising its z-index worked everywhere, but leaves the badge sitting
on the chip rather than clear of it; the z-index is kept as a backstop, the move is
what does the work.
What it costs
At 360x640 heads-up the bottom seat's badge moves down into the action bar, where it
was visible before. That seat is already substantially behind the bar at that size --
the narrow layout reserves scroll room rather than capping the felt, which #63
measured and settled on -- but this change is what put the badge there, so I am
saying so rather than leaving it to be found. Every other size and seat count is
clean, including 360x640 at six and nine seats.
An instrument note, because it nearly became a finding. Bet chips are
pointer-events: none above 900px and auto below it, so elementFromPoint reports them
as covering things on a phone and as invisible on a desktop. The 35% figures above
are geometric overlap and a cropped screenshot of the rendered pixels, not hit tests.
Three "chip covered by DIV" results I saw earlier were that quirk and are not real.
214 tests still passing; layout audit and contrast clean at 8 sizes x 3 seat counts;
no console errors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 17 +++++++++++++++++
.../wwwroot/js/casino/holdem/render.js | 10 ++++++++++
2 files changed, 27 insertions(+)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 84c6fec..2b5f8ef 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -695,6 +695,23 @@ button,
display: inline-block;
}
+/* Seats on the bottom of the ring wear their badge underneath, because that's the
+ edge the pot isn't on. Bets sit between a seat and the pot, so on the bottom arc
+ the chip comes down onto the seat's top edge — which is exactly where this badge
+ was, and the chip is a layer above it. See render.js for the measurement. */
+.seat.seatBelowCentre .blindBadge {
+ top: auto;
+ bottom: -9px;
+}
+
+/* And the badge outranks the chips regardless, so a layout this doesn't anticipate
+ can't hide it again. Which blind a seat is posting is the one thing on that puck
+ nothing else on the felt repeats — a bet chip's amount is on the seat's own total
+ line as well. Measured across 12 table sizes: no chip loses its amount to this. */
+.blindBadge {
+ z-index: 4;
+}
+
/* Both badges failed WCAG AA against their own gradients — measured at the worst
stop, the small blind was 3.52:1 and the big blind 2.15:1, in 9px bold text a
dealer is meant to read at a glance across a table.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 6d07fc7..54b3712 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -171,6 +171,16 @@ const Render = {
node.style.top = `${pos.top}%`;
node.dataset.seatIndex = String(index);
+ // Which side of the seat the pot is on, which is the side its bet chip
+ // arrives from — the chip sits on the same spoke, closer in. For a seat on
+ // the top of the ring that's from below; on the bottom, from above.
+ //
+ // The blind badge has to be on the other side or the chip lands on top of
+ // it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of
+ // the badge's 288 square pixels, and it sits a layer higher, so the marker
+ // telling the dealer who the big blind is was simply gone.
+ node.classList.toggle("seatBelowCentre", pos.top > 50);
+
node.querySelector(".seatChairNumber").textContent = index + 1;
const dealerChip = node.querySelector(".dealerChip");
From 286a924fe40f4f782aec75d28f378f799e604aba Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 14:14:04 -0600
Subject: [PATCH 10/55] Give buttons and links the same box
The header on a portrait phone was 227px -- a quarter of the screen -- before the
table started, and at 360x640 the bottom of the table sat 16px behind the action bar
with the gesture hint off screen entirely.
Chasing where the pixels went turned up something better than a tight header.
.holdemBtn is worn by three
+
+
Redeal it — misdeal
@@ -383,6 +385,22 @@
+
+
Betting in one motion — the string bet
+
+ A bet is whatever goes in on the first trip to the stack, unless the
+ amount was said out loud first. Reaching back for more chips without
+ having said "raise" is a string bet, and only the first motion stands.
+ Saying the number is always enough on its own: "raise to sixty" binds
+ whatever order the chips arrive in.
+
+
+ It's the rule home games invoke most and enforce least, and both are
+ fine — the point is that a player can't watch the table react and then
+ decide how much to bet. Say it, or push it all at once.
+
+
+
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 8d39b27..736857c 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1095,6 +1095,51 @@ button,
line-height: 1.5;
}
+/* One chip per ruling, wrapped across the top of the sheet. Names rather than icons:
+ a dealer scanning this is looking for the words somebody at the table just said. */
+.rulingsIndex {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin: 0 0 1rem;
+}
+
+.rulingsIndexBtn {
+ box-sizing: border-box;
+ min-height: 32px;
+ padding: 0.3rem 0.6rem;
+ border-radius: 999px;
+ border: 1px solid #3a3a3a;
+ background: #232323;
+ color: #d8b46a;
+ font-family: inherit;
+ font-size: 0.72rem;
+ line-height: 1.25;
+ text-align: left;
+ cursor: pointer;
+}
+
+@media (hover: hover) {
+ .rulingsIndexBtn:hover {
+ background: #2e2e2e;
+ border-color: #4a4a4a;
+ }
+}
+
+/* A thumb needs the same 44px here as everywhere else. */
+@media (pointer: coarse) {
+ .rulingsIndexBtn {
+ min-height: 44px;
+ font-size: 0.78rem;
+ }
+}
+
+/* The heading the index jumps to takes focus, and a focus ring on a heading that the
+ dealer just asked for reads as noise rather than as help. */
+.ruling h3:focus {
+ outline: none;
+}
+
.ruling {
margin-bottom: 1rem;
padding-left: 0.7rem;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index c25543f..abcbcc8 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -458,7 +458,42 @@ document.addEventListener("DOMContentLoaded", () => {
// Escape is Modal's job now, for this sheet and the five others that never had it.
const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; };
- document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
+ // An index across the top of the rulings sheet, one chip per ruling.
+ //
+ // This is opened mid-argument with players waiting, and it was nine rulings of
+ // prose with no way in: reaching the last one meant scrolling 1.6 screens on a
+ // desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
+ // the odd-chip rule had to read past seven others to find it.
+ //
+ // Built from the sections rather than written out beside them, so a ruling added
+ // later turns up in the index without anyone having to remember to add it twice.
+ const buildRulingsIndex = () => {
+ const index = document.getElementById("rulingsIndex");
+ if (!index || index.childElementCount > 0) return;
+ [...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
+ const heading = section.querySelector("h3");
+ if (!heading) return;
+ if (!section.id) section.id = `ruling${i + 1}`;
+
+ const jump = document.createElement("button");
+ jump.type = "button";
+ jump.className = "rulingsIndexBtn";
+ jump.textContent = heading.textContent;
+ jump.addEventListener("click", () => {
+ section.scrollIntoView({ block: "start", behavior: "smooth" });
+ // The heading is what the dealer is now reading, so that is where the
+ // keyboard and a screen reader should be too.
+ heading.setAttribute("tabindex", "-1");
+ heading.focus({ preventScroll: true });
+ });
+ index.appendChild(jump);
+ });
+ };
+
+ document.getElementById("openRulingsBtn").addEventListener("click", () => {
+ buildRulingsIndex();
+ rulingsOverlay.hidden = false;
+ });
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
From ebf22e315378145724409ecda80e3947e92bea5b Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 17:36:37 -0600
Subject: [PATCH 16/55] Shorten the rulings index to the words a dealer would
say
Last change added an index to the rulings sheet and I wrote down what it cost: 342px
at the top of a 390px-wide sheet, so a dealer reading straight through scrolls past
the whole thing first. That was an honest trade at the time. It is also fixable.
The index was labelled with the section headings, which are sentences -- "A short
all-in doesn't reopen the betting" -- so nine of them stacked one per row. A dealer
hunting for that ruling is looking for the words "short all-in", not the sentence.
Misdeal Board card early Out of turn
Short all-in Uncalled bet Oversized chip
Minimum raise Odd chip String bet
phone 342px -> 143px 9 rows -> 3
desktop ~200 -> 70px -> 2
landscape -> 94px -> 2
The first ruling now starts 309px into the sheet on a phone rather than 440.
Each short label lives on its own section as data-short, next to the heading it
shortens, and the index falls back to the heading when there is not one -- so a ruling
added later still appears, still without anyone having to write it twice. The full
sentence stays as the button's aria-label, so the short label never costs a screen
reader the heading.
Checked at four sizes across touch and pointer contexts: nine labels, nine aria-labels,
every chip 44px on touch, jumping still lands and focuses the right heading, contrast
clean, no console errors. 244 tests still passing.
Also looked at, and left alone: the card picker refuses a card already dealt --
disabled, titled "Already dealt to Ann" -- and every one of its 52 buttons clears 44px.
A messy nine-seat table with 25/50 blinds, two folded players, a busted seat, accented
and hyphenated names, a $125,000 stack and a filled notes field audits clean at every
size but the landscape phone already known from #63.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 18 +++++++++---------
JoshHeaps.Net/wwwroot/js/casino/holdem/main.js | 10 +++++++++-
2 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index d60234f..026a3cd 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -296,7 +296,7 @@
-
+
Redeal it — misdeal
The first card goes to the wrong seat.
@@ -306,7 +306,7 @@
-
+
A board card turned too early
If the fourth board card gets turned before the betting round is finished,
@@ -315,7 +315,7 @@
-
+
Acting out of turn
A one-off is usually just backed up if no one has acted behind them.
@@ -323,7 +323,7 @@
-
+
A short all-in doesn't reopen the betting
Somebody shoving in less than a full raise — say $3 more when the last
@@ -338,7 +338,7 @@
-
+
Chips nobody called come back
Bet more than anyone was able to call and the excess is returned to you
@@ -348,7 +348,7 @@
-
+
One big chip, nothing said
The most common argument at a home table. A player facing a bet who
@@ -364,7 +364,7 @@
-
+
A raise has to be a full raise
At least as much again as the last raise, or the big blind if there
@@ -375,7 +375,7 @@
-
+
The odd chip in a split pot
When a chopped pot won't divide evenly, the odd chip goes to the first
@@ -385,7 +385,7 @@
-
+
Betting in one motion — the string bet
A bet is whatever goes in on the first trip to the stack, unless the
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index abcbcc8..2badb52 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -478,7 +478,15 @@ document.addEventListener("DOMContentLoaded", () => {
const jump = document.createElement("button");
jump.type = "button";
jump.className = "rulingsIndexBtn";
- jump.textContent = heading.textContent;
+ // A short label where the ruling gives one. The headings are sentences —
+ // "A short all-in doesn't reopen the betting" — and nine of those stacked
+ // one per row made the index 342px tall on a phone, which is most of the
+ // sheet before the first ruling. "Short all-in" says the same thing to
+ // somebody who is looking for it, and three fit on a line.
+ jump.textContent = section.dataset.short || heading.textContent;
+ // The full heading is still what it announces, so the short label never
+ // costs a screen reader the sentence.
+ if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
jump.addEventListener("click", () => {
section.scrollIntoView({ block: "start", behavior: "smooth" });
// The heading is what the dealer is now reading, so that is where the
From 8a7d7b09bfb02ea7fd74ebdd93d23e98d2f22178 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 17:52:52 -0600
Subject: [PATCH 17/55] Print the two shortcuts a dealer had no way to find
Seven keys drive this tool. Five of them are printed on the control they press -- F on
Fold, C on Call, R on Raise, the return arrow on Deal and on Raise to, esc on Cancel.
Two were printed on nothing at all:
A opens the amount pad on All-in
U takes back the last action
A dealer who learned F, C and R from the buttons had no way to discover the other two
existed. They are the two worth knowing: A is the fastest action in the tool and U is
the one that saves a misclick.
Both are on their controls now. The key lookup matches any of a button's classes, so
the raise pad's four quick amounts can share one class for their looks while only
All-in carries a key.
And a legend in the sidebar, listing all seven with what they do, built from the same
list the handlers are keyed off. There is a test that every key in the map has a line
in the legend and that every line names a key that really is bound -- a shortcut with
no line is one nobody can find, which is the whole bug this fixes.
The panel is hidden on a touch device. A phone has no keys to press, and it is the
same pointer question the touch-target rules ask rather than a guess about width.
One thing I broke and fixed on the way. .actionKey pins itself to the top-right corner
of a big action button. The undo control is a 30px pill with overflow hidden and no
positioning context, so the U escaped it and floated above the action bar -- visible in
the screenshot before I looked properly. It has its own inline class now, checked at
six sizes: the cap sits inside the pill and inside the bar, and the pill is not
clipped.
5 tests (249 total). Driven at six sizes across touch and pointer contexts: the undo
pill shows U, the raise pad shows A on All-in and nothing on Min, half pot and Pot, the
legend lists all seven, and it is display:none on a phone. Contrast clean, layout audit
clean but for the landscape phone already known from #63, no console errors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 8 +++
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 54 +++++++++++++++++++
.../wwwroot/js/casino/holdem/actionBar.js | 20 ++++++-
.../wwwroot/js/casino/holdem/main.js | 19 +++++++
.../wwwroot/js/casino/holdem/shortcuts.js | 17 ++++++
tests/holdem/shortcutLegend.test.js | 51 ++++++++++++++++++
6 files changed, 167 insertions(+), 2 deletions(-)
create mode 100644 tests/holdem/shortcutLegend.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 026a3cd..f848c08 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -156,6 +156,14 @@
+
+ Keyboard
+
+
+
Nothing fires while you're typing in a field, or while a sheet is open over the table.
+
+
+
Table Notes
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 736857c..acb3a7b 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1095,6 +1095,46 @@ button,
line-height: 1.5;
}
+/* The shortcut legend. A touch device has no keys to press, so it doesn't get a panel
+ telling it about them — the same pointer question the touch-target rules ask. */
+@media (pointer: coarse) {
+ #keyboardPanel {
+ display: none;
+ }
+}
+
+.shortcutLegend {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.45rem 0.6rem;
+ margin: 0;
+ align-items: baseline;
+}
+
+.shortcutLegend dt {
+ display: flex;
+ gap: 0.2rem;
+}
+
+.shortcutLegend dd {
+ margin: 0;
+ color: #b8b8b8;
+ font-size: 0.78rem;
+ line-height: 1.35;
+}
+
+.shortcutKey {
+ background: #232323;
+ border: 1px solid #3f3f3f;
+ border-bottom-width: 2px;
+ border-radius: 4px;
+ padding: 0.05rem 0.32rem;
+ font-family: inherit;
+ font-size: 0.68rem;
+ color: #d8b46a;
+ white-space: nowrap;
+}
+
/* One chip per ruling, wrapped across the top of the sheet. Names rather than icons:
a dealer scanning this is looking for the words somebody at the table just said. */
.rulingsIndex {
@@ -2117,6 +2157,20 @@ body.hasActionBar #holdemSidebar {
font-variant-numeric: tabular-nums;
}
+/* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */
+.actionUndoKey {
+ margin-left: 0.4rem;
+ padding: 0 0.25rem;
+ border-radius: 4px;
+ border: 1px solid currentColor;
+ opacity: 0.55;
+ font-family: inherit;
+ font-size: 0.6rem;
+ line-height: 1.4;
+ text-transform: uppercase;
+ pointer-events: none;
+}
+
.actionKey {
position: absolute;
top: 5px;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 647c051..1998e6e 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -176,6 +176,17 @@ const ActionBar = {
undo.className = "actionUndo";
undo.title = `Undo ${undoLabel}`;
undo.textContent = `↶ ${undoLabel}`;
+ // U undoes, and nothing said so — this control was the only place a dealer
+ // could have learned it, and it was the one action bar control without its
+ // key printed on it.
+ const key = document.createElement("kbd");
+ // Its own class, not .actionKey: that one pins itself to the top-right
+ // corner of a big action button, and this control is a 30px pill with
+ // overflow hidden and no positioning context — the cap escaped it and
+ // floated above the bar. Here the key belongs inline, after the label.
+ key.className = "actionUndoKey";
+ key.textContent = this.KEY_HINTS.actionUndo;
+ undo.appendChild(key);
undo.addEventListener("click", () => this.hooks.undoLast());
row.appendChild(undo);
}
@@ -281,6 +292,8 @@ const ActionBar = {
actionAdvance: "↵",
raiseConfirm: "↵",
raiseCancel: "esc",
+ raiseAllIn: "A",
+ actionUndo: "U",
},
button(label, className, onClick, sublabel) {
@@ -293,7 +306,10 @@ const ActionBar = {
main.textContent = label;
btn.appendChild(main);
- const hint = this.KEY_HINTS[className];
+ // Looked up by any of the button's classes, so a button can carry a shared
+ // class for its looks and a specific one for its key — the raise pad's four
+ // quick amounts are all raiseQuickBtn, but only All-in has a key.
+ const hint = className.split(/\s+/).map(name => this.KEY_HINTS[name]).find(Boolean);
if (hint) {
const kbd = document.createElement("kbd");
kbd.className = "actionKey";
@@ -397,7 +413,7 @@ const ActionBar = {
];
options.forEach(option => {
const value = Math.max(minTo, Math.min(option.value, maxTo));
- const btn = this.button(option.label, "raiseQuickBtn", () => {
+ const btn = this.button(option.label, `raiseQuickBtn${option.label === "All-in" ? " raiseAllIn" : ""}`, () => {
this.raiseDraft = value;
this.render();
}, Money.format(value));
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 2badb52..e2be2ca 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -458,6 +458,25 @@ document.addEventListener("DOMContentLoaded", () => {
// Escape is Modal's job now, for this sheet and the five others that never had it.
const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; };
+ // The shortcut legend, built from the same list the handlers are keyed off, so a
+ // shortcut can't exist without a line here saying what it does.
+ const legend = document.getElementById("shortcutLegend");
+ if (legend) {
+ Shortcuts.LEGEND.forEach(entry => {
+ const term = document.createElement("dt");
+ entry.keys.forEach(key => {
+ const kbd = document.createElement("kbd");
+ kbd.className = "shortcutKey";
+ kbd.textContent = key;
+ term.appendChild(kbd);
+ });
+ const detail = document.createElement("dd");
+ detail.textContent = entry.what;
+ legend.appendChild(term);
+ legend.appendChild(detail);
+ });
+ }
+
// An index across the top of the rulings sheet, one chip per ruling.
//
// This is opened mid-argument with players waiting, and it was nine rulings of
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js
index 4843a64..35f1fb5 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js
@@ -18,6 +18,23 @@ const Shortcuts = {
escape: "cancel",
},
+ // What each one does, in a dealer's words, for the legend in the sidebar.
+ //
+ // Lives beside KEYS because the two must not drift: F, C and R are printed on the
+ // buttons they drive, but A and U were on nothing at all — a dealer who learned
+ // the first three from the bar had no way to find out the other two existed. A
+ // shortcut with no line here is a shortcut nobody can find, and there's a test
+ // that says so.
+ LEGEND: [
+ { keys: ["F"], action: "fold", what: "Fold" },
+ { keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
+ { keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
+ { keys: ["A"], action: "allIn", what: "Put this player all-in" },
+ { keys: ["U"], action: "undo", what: "Take back the last action" },
+ { keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
+ { keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
+ ],
+
mount(handlers) {
this.handlers = handlers;
document.addEventListener("keydown", e => this.onKey(e));
diff --git a/tests/holdem/shortcutLegend.test.js b/tests/holdem/shortcutLegend.test.js
new file mode 100644
index 0000000..966333b
--- /dev/null
+++ b/tests/holdem/shortcutLegend.test.js
@@ -0,0 +1,51 @@
+// The legend and the key map must not drift apart.
+//
+// F, C and R are printed on the buttons they drive. A and U were on nothing at all,
+// so a dealer who learned the first three from the action bar had no way to find out
+// the other two existed. The guard against that happening again is this file.
+const test = require("node:test");
+const assert = require("node:assert");
+const path = require("path");
+
+const Shortcuts = require(path.join(
+ __dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
+
+test("every shortcut has a line in the legend", () => {
+ const documented = new Set(Shortcuts.LEGEND.map(entry => entry.action));
+ const missing = [...new Set(Object.values(Shortcuts.KEYS))].filter(action => !documented.has(action));
+ assert.deepEqual(missing, [], "a shortcut with no legend line is one nobody can find");
+});
+
+test("the legend doesn't describe shortcuts that don't exist", () => {
+ const real = new Set(Object.values(Shortcuts.KEYS));
+ const invented = Shortcuts.LEGEND.map(entry => entry.action).filter(action => !real.has(action));
+ assert.deepEqual(invented, []);
+});
+
+test("every legend line names its keys and says what they do", () => {
+ Shortcuts.LEGEND.forEach(entry => {
+ assert.ok(Array.isArray(entry.keys) && entry.keys.length > 0, `${entry.action} has no keys`);
+ assert.ok(entry.keys.every(key => typeof key === "string" && key.trim()), `${entry.action} has a blank key`);
+ assert.ok(entry.what && entry.what.trim().length > 3, `${entry.action} has no description`);
+ });
+});
+
+test("the keys named match the keys bound", () => {
+ // "Space" and "Enter" both drive advance; the map holds them as " " and "enter".
+ const spoken = { space: " ", enter: "enter", esc: "escape" };
+ Shortcuts.LEGEND.forEach(entry => {
+ entry.keys.forEach(label => {
+ const key = spoken[label.toLowerCase()] || label.toLowerCase();
+ assert.equal(Shortcuts.KEYS[key], entry.action,
+ `the legend says ${label} is ${entry.action}, the map disagrees`);
+ });
+ });
+});
+
+test("no two lines claim the same key", () => {
+ const seen = new Set();
+ Shortcuts.LEGEND.forEach(entry => entry.keys.forEach(key => {
+ assert.ok(!seen.has(key.toLowerCase()), `${key} is listed twice`);
+ seen.add(key.toLowerCase());
+ }));
+});
From 4071be53292eeaf991980efabb277fcf26bc655c Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 18:08:05 -0600
Subject: [PATCH 18/55] Say so when the browser refuses to save
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every save goes through one try/catch, and the catch was empty:
} catch {
// Storage full/unavailable (private browsing, etc.) — the table still works
// for the rest of the session, it just won't survive a refresh.
}
The comment is accurate and the behaviour is right -- the chips have already moved in
memory and refusing to continue would be worse. What was missing is anybody being
told. Driven with setItem throwing: two saves failed, no toast, nothing on the page,
and a refresh came back to before the hand was dealt with the dealer none the wiser.
That is the same shape of fault the bank reconciliation exists for. Silent until it is
too late to act on, and the way you find out is the last two players cashing out.
Now it says so twice, in the two places that suit the two timescales.
A toast the first time, and only the first time -- a dealer taps this bar thirty times
a hand and does not need thirty warnings. Measured: four failed saves, one toast.
"This table isn't being saved — refreshing would lose it. Keep this tab open."
And a standing line at the top of the Session panel, because a toast is a moment and
this is a fact about the rest of the night. The raise pad dismisses toasts, and the one
warning that must not be missed is not the one to leave to a toast.
"This table isn't being saved — the browser refused. It'll keep working, but
closing or refreshing this tab loses the session. Export CSV below keeps a copy."
Amber, like the bank check's disagreement, because both mean the same thing: your
record of the night is wrong and you can still do something about it. The export is
named because it is the thing that helps.
6 tests (255 total): a good save reports success and leaves no mark, a failing one
reports failure rather than throwing, the listener fires exactly once across twenty
failures, it is handed the error so the reason can be reported later, a failing save
still does not stop the table, and no listener at all is not an error.
Driven through the UI with setItem throwing: hidden while saving works, toast on the
first failure, zero further toasts across three more actions, the panel line present
and readable. Contrast clean, layout audit clean, no console errors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 1 +
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 13 +++
.../wwwroot/js/casino/holdem/main.js | 10 ++
.../wwwroot/js/casino/holdem/render.js | 15 +++
.../wwwroot/js/casino/holdem/state.js | 26 ++++-
tests/holdem/saveFailure.test.js | 95 +++++++++++++++++++
6 files changed, 157 insertions(+), 3 deletions(-)
create mode 100644 tests/holdem/saveFailure.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index f848c08..65d9fdb 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -103,6 +103,7 @@
Session
+
Standings
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index acb3a7b..1d7deca 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1095,6 +1095,19 @@ button,
line-height: 1.5;
}
+/* Amber, like the bank check's disagreement — both are "your record of the night is
+ wrong and you can still do something about it". */
+.storageWarning {
+ margin: 0;
+ padding: 0.5rem 0.6rem;
+ border: 1px solid rgba(240, 180, 106, 0.45);
+ border-radius: 7px;
+ background: rgba(240, 180, 106, 0.08);
+ color: #f0b46a;
+ font-size: 0.76rem;
+ line-height: 1.4;
+}
+
/* The shortcut legend. A touch device has no keys to press, so it doesn't get a panel
telling it about them — the same pointer question the touch-target rules ask. */
@media (pointer: coarse) {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index e2be2ca..787d8c5 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -458,6 +458,16 @@ document.addEventListener("DOMContentLoaded", () => {
// Escape is Modal's job now, for this sheet and the five others that never had it.
const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; };
+ // The one time the browser refuses to save, say so out loud as well as leaving the
+ // standing warning in the Session panel. Set before the first render so a failure
+ // during start-up is caught too.
+ HoldemState.onSaveFailed = () => {
+ Toast.show(
+ "This table isn't being saved — refreshing would lose it. Keep this tab open.",
+ { duration: 12000 });
+ Render.renderStorageWarning();
+ };
+
// The shortcut legend, built from the same list the handlers are keyed off, so a
// shortcut can't exist without a line here saying what it does.
const legend = document.getElementById("shortcutLegend");
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 87171fb..8d57b9e 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -751,6 +751,21 @@ const Render = {
this.renderBankReconciliation();
this.renderSettleUp(standings);
+ this.renderStorageWarning();
+ },
+
+ // A toast is a moment; this table not being saved is a fact about the rest of the
+ // night. It sits at the top of the panel a dealer opens to check the money, and it
+ // stays there, because the raise pad dismisses toasts and the one time this warning
+ // matters is the one time it must not have been missed.
+ renderStorageWarning() {
+ const host = document.getElementById("storageWarning");
+ if (!host) return;
+ host.hidden = !HoldemState.saveFailed;
+ if (host.hidden) return;
+ host.textContent =
+ "This table isn't being saved — the browser refused. It'll keep working, " +
+ "but closing or refreshing this tab loses the session. Export CSV below keeps a copy.";
},
// The last step the standings stopped short of. Six signed numbers at 2am is not
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 09e9aa7..b1abcdd 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -122,12 +122,32 @@ const HoldemState = {
}
},
+ // True once a save has failed. The UI reads this to keep saying so, because a
+ // toast is a moment and this is a fact about the rest of the night.
+ saveFailed: false,
+
+ // Called the first time a save fails. Set by the UI.
+ onSaveFailed: null,
+
save(state) {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
- } catch {
- // Storage full/unavailable (private browsing, etc.) — the table still works
- // for the rest of the session, it just won't survive a refresh.
+ return true;
+ } catch (error) {
+ // Storage full, or private browsing. The table still works for the rest of
+ // the session — it just won't survive a refresh, and nothing used to say
+ // so. That is the same shape of fault the bank check exists for: silent
+ // until it is too late to do anything about it. A dealer who finds out by
+ // refreshing has lost the night's standings, and the only warning they got
+ // was the table being empty.
+ //
+ // Announced once. A dealer tapping a bar thirty times a hand does not need
+ // thirty of these.
+ if (!this.saveFailed) {
+ this.saveFailed = true;
+ if (typeof this.onSaveFailed === "function") this.onSaveFailed(error);
+ }
+ return false;
}
},
diff --git a/tests/holdem/saveFailure.test.js b/tests/holdem/saveFailure.test.js
new file mode 100644
index 0000000..abe6c12
--- /dev/null
+++ b/tests/holdem/saveFailure.test.js
@@ -0,0 +1,95 @@
+// What happens when the browser refuses to save.
+//
+// The table keeps working — that was always true and is still the right behaviour.
+// What was missing is anybody being told, which is the same shape of fault the bank
+// check exists for: silent until it's too late to act on.
+const test = require("node:test");
+const assert = require("node:assert");
+const { HoldemState, makeTable } = require("./load");
+
+function withStorage(behaviour) {
+ const calls = [];
+ globalThis.localStorage = {
+ setItem(key, value) {
+ calls.push({ key, value });
+ if (behaviour === "throw") {
+ const error = new Error("QuotaExceededError");
+ error.name = "QuotaExceededError";
+ throw error;
+ }
+ },
+ getItem: () => null,
+ removeItem() {},
+ };
+ return calls;
+}
+
+test.beforeEach(() => {
+ HoldemState.saveFailed = false;
+ HoldemState.onSaveFailed = null;
+});
+
+test.after(() => {
+ delete globalThis.localStorage;
+ HoldemState.saveFailed = false;
+ HoldemState.onSaveFailed = null;
+});
+
+test("a save that works says so and leaves no mark", () => {
+ withStorage("ok");
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+
+ assert.equal(HoldemState.save(state), true);
+ assert.equal(HoldemState.saveFailed, false);
+});
+
+test("a save that fails says so rather than throwing", () => {
+ withStorage("throw");
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+
+ assert.equal(HoldemState.save(state), false);
+ assert.equal(HoldemState.saveFailed, true);
+});
+
+test("the table is told once, not on every action", () => {
+ // A dealer taps the bar thirty times a hand and doesn't need thirty warnings.
+ withStorage("throw");
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+ let told = 0;
+ HoldemState.onSaveFailed = () => { told++; };
+
+ for (let i = 0; i < 20; i++) HoldemState.save(state);
+
+ assert.equal(told, 1);
+});
+
+test("the failure is handed the error, so the reason can be reported later", () => {
+ withStorage("throw");
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+ let seen = null;
+ HoldemState.onSaveFailed = error => { seen = error; };
+
+ HoldemState.save(state);
+
+ assert.equal(seen && seen.name, "QuotaExceededError");
+});
+
+test("a failing save still doesn't stop the table", () => {
+ // The chips have already moved in memory; refusing to continue would be worse
+ // than not persisting.
+ withStorage("throw");
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
+ state.seats[0].stack = 175;
+
+ assert.doesNotThrow(() => HoldemState.save(state));
+ assert.equal(state.seats[0].stack, 175);
+});
+
+test("no listener is not an error", () => {
+ withStorage("throw");
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+ HoldemState.onSaveFailed = null;
+
+ assert.doesNotThrow(() => HoldemState.save(state));
+ assert.equal(HoldemState.saveFailed, true);
+});
From cfb83a2ba6cf4e03d46793c14ece6d7a8ed39185 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 18:22:31 -0600
Subject: [PATCH 19/55] Don't quietly write over a save that couldn't be read
Last change made a failing save say so. This is the other direction, and it turned out
to be worse.
load() is well defended -- try/catch, a shape check, sensible fallbacks -- and every
one of those paths ends in `return this.createDefault()`. So an unreadable save becomes
an empty table. Driven with a save truncated to 70% of its length, the way an
interrupted write leaves one:
1762 bytes stored, 1233 after truncating
loaded: 0 named seats, hand #1 instead of #34
toast: none. warning: none. anything on the page: no.
A dealer mid-session sees an empty felt and cannot tell whether they mis-tapped Reset,
whether the browser lost the night, or what.
And then the first edit destroys the evidence. I typed a name into a seat and storage
went from the 1233 unreadable bytes to 2549 bytes of fresh default state. Whatever a
person could have picked out of that JSON -- the names, the buy-ins, the standings --
was gone one tap after the tool decided it could not read it.
Now the unreadable value is moved aside to its own key before the fallback, and the
table says what happened:
toast: "The saved table couldn't be read, so this one started empty."
panel: "The saved table couldn't be read, so this one started empty. The
unreadable copy has been kept rather than written over."
It is deliberately not offered as a restore. Half a JSON document is not a table, and
pretending otherwise would be worse than an empty one. It just stops being destroyed.
The two warnings share the Session panel line and both can show at once -- a browser
having trouble with storage tends to have it in both directions -- with the load
message first, because "why is my table empty" is the question the dealer is standing
there asking.
8 tests (263 total): nothing stored is a new table rather than a failure, a good save
loads clean, a truncated one is reported, valid JSON of the wrong shape counts too, the
copy is kept, a later save does not touch it, the listener fires once across three
reads, and storage with no room to keep the copy is not a crash -- which is exactly the
storage most likely to hand back half a value in the first place.
Driven through the UI: warning and toast on a corrupted save, the set-aside copy still
1233 bytes intact after editing a seat, and nothing shown at all on a healthy table.
One audit correction rides along. The collapsed-column check measured inside hidden
elements, so the standings header -- hidden with its table until somebody buys in --
was reported as three collapsed columns on every empty table. It skips hidden subtrees
now, and still catches a real one: squeezing a visible column reports the header and
all three rows.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/main.js | 10 ++
.../wwwroot/js/casino/holdem/render.js | 21 +++-
.../wwwroot/js/casino/holdem/state.js | 40 +++++-
tests/holdem/loadFailure.test.js | 114 ++++++++++++++++++
tests/holdem/uiAudit.js | 6 +
5 files changed, 184 insertions(+), 7 deletions(-)
create mode 100644 tests/holdem/loadFailure.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 787d8c5..a68e329 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -571,4 +571,14 @@ document.addEventListener("DOMContentLoaded", () => {
if ((state.handHistory || []).length === 0) return;
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
});
+
+ // The load happens on the first line of this file, long before there's a Toast to
+ // show it in, so this is checked here rather than wired as a callback. The standing
+ // warning in the Session panel doesn't need it — that reads the flag every render —
+ // but an empty table is a thing a dealer needs told, not left to notice.
+ if (HoldemState.loadFailed) {
+ Toast.show(
+ "The saved table couldn't be read, so this one started empty.",
+ { duration: 12000 });
+ }
});
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 8d57b9e..068e8f0 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -761,11 +761,24 @@ const Render = {
renderStorageWarning() {
const host = document.getElementById("storageWarning");
if (!host) return;
- host.hidden = !HoldemState.saveFailed;
+ host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed;
if (host.hidden) return;
- host.textContent =
- "This table isn't being saved — the browser refused. It'll keep working, " +
- "but closing or refreshing this tab loses the session. Export CSV below keeps a copy.";
+
+ // Both can be true at once — a browser having trouble with storage tends to
+ // have it in both directions — and the one that explains an empty table goes
+ // first, because that's the question the dealer is standing there asking.
+ const lines = [];
+ if (HoldemState.loadFailed) {
+ lines.push(
+ "The saved table couldn't be read, so this one started empty. " +
+ "The unreadable copy has been kept rather than written over.");
+ }
+ if (HoldemState.saveFailed) {
+ lines.push(
+ "This table isn't being saved — the browser refused. It'll keep working, " +
+ "but closing or refreshing this tab loses the session. Export CSV below keeps a copy.");
+ }
+ host.textContent = lines.join(" ");
},
// The last step the standings stopped short of. Six signed numbers at 2am is not
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index b1abcdd..97d2484 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -81,12 +81,45 @@ const HoldemState = {
};
},
+ // Where an unreadable save is kept instead of being written over.
+ RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
+
+ // True when there was a saved table and it couldn't be read. Same pair as
+ // saveFailed above, for the same reason: the UI has to be able to say so.
+ loadFailed: false,
+ onLoadFailed: null,
+
+ // A saved table that won't parse becomes an empty table, which is the only thing
+ // it can become — but it used to become one silently, and then the first edit
+ // saved over the top of it. A dealer mid-session saw an empty felt with no idea
+ // whether they'd mis-tapped Reset or the browser had lost the night, and whatever
+ // a person might have picked out of the raw JSON was gone a tap later.
+ //
+ // So the unreadable value is moved aside first. It is not offered as a restore —
+ // half a JSON document isn't a table, and pretending otherwise would be worse than
+ // an empty one — but it stops being destroyed, and somebody who wants the
+ // standings out of it still can.
+ failedLoad(raw) {
+ if (!this.loadFailed) {
+ this.loadFailed = true;
+ try {
+ if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
+ } catch {
+ // No room to keep it. Nothing more to try, and the warning below is
+ // the part that actually matters.
+ }
+ if (typeof this.onLoadFailed === "function") this.onLoadFailed();
+ }
+ return this.createDefault();
+ },
+
load() {
+ let raw = null;
try {
- const raw = localStorage.getItem(this.STORAGE_KEY);
+ raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const parsed = JSON.parse(raw);
- if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
+ if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
@@ -118,7 +151,8 @@ const HoldemState = {
return parsed;
} catch {
- return this.createDefault();
+ // Malformed JSON — a truncated write, another tab, a browser hiccup.
+ return this.failedLoad(raw);
}
},
diff --git a/tests/holdem/loadFailure.test.js b/tests/holdem/loadFailure.test.js
new file mode 100644
index 0000000..a338ddf
--- /dev/null
+++ b/tests/holdem/loadFailure.test.js
@@ -0,0 +1,114 @@
+// What happens when the saved table can't be read.
+//
+// An unreadable save can only become an empty table. What it must not do is become
+// one silently and then get written over by the first edit.
+const test = require("node:test");
+const assert = require("node:assert");
+const { HoldemState } = require("./load");
+
+function withStored(value, { readOnly = false } = {}) {
+ const store = new Map();
+ if (value !== undefined) store.set(HoldemState.STORAGE_KEY, value);
+ globalThis.localStorage = {
+ getItem: key => (store.has(key) ? store.get(key) : null),
+ setItem(key, val) {
+ if (readOnly) throw new Error("QuotaExceededError");
+ store.set(key, val);
+ },
+ removeItem: key => store.delete(key),
+ };
+ return store;
+}
+
+test.beforeEach(() => {
+ HoldemState.loadFailed = false;
+ HoldemState.onLoadFailed = null;
+});
+
+test.after(() => {
+ delete globalThis.localStorage;
+ HoldemState.loadFailed = false;
+ HoldemState.onLoadFailed = null;
+});
+
+test("nothing stored is a new table, not a failure", () => {
+ withStored(undefined);
+ const state = HoldemState.load();
+
+ assert.equal(HoldemState.loadFailed, false);
+ assert.ok(Array.isArray(state.seats));
+});
+
+test("a good save loads and reports nothing", () => {
+ const store = withStored(undefined);
+ const original = HoldemState.createDefault();
+ original.handNumber = 34;
+ HoldemState.save(original);
+
+ const state = HoldemState.load();
+ assert.equal(HoldemState.loadFailed, false);
+ assert.equal(state.handNumber, 34);
+ assert.equal(store.has(HoldemState.RECOVERY_KEY), false);
+});
+
+test("a truncated save is reported rather than silently becoming an empty table", () => {
+ const good = JSON.stringify(HoldemState.createDefault());
+ withStored(good.slice(0, Math.floor(good.length * 0.7)));
+
+ const state = HoldemState.load();
+ assert.equal(HoldemState.loadFailed, true);
+ assert.ok(Array.isArray(state.seats), "it still hands back a usable table");
+});
+
+test("valid JSON of the wrong shape counts as unreadable too", () => {
+ withStored(JSON.stringify({ hello: "world" }));
+
+ HoldemState.load();
+ assert.equal(HoldemState.loadFailed, true);
+});
+
+test("the unreadable copy is kept instead of being written over", () => {
+ // This is the half that matters: whatever a person could have picked out of the
+ // raw JSON used to be gone the moment the dealer touched a seat.
+ const broken = '{"seats":[{"name":"Ann","stack":250},{"name":"Ben"';
+ const store = withStored(broken);
+
+ HoldemState.load();
+
+ assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
+});
+
+test("a later save doesn't touch the copy that was set aside", () => {
+ const broken = '{"seats":[{"name":"Ann"';
+ const store = withStored(broken);
+
+ const state = HoldemState.load();
+ state.handNumber = 2;
+ HoldemState.save(state);
+
+ assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
+ assert.notEqual(store.get(HoldemState.STORAGE_KEY), broken);
+});
+
+test("the listener fires once, not on every read", () => {
+ withStored("{not json");
+ let told = 0;
+ HoldemState.onLoadFailed = () => { told++; };
+
+ HoldemState.load();
+ HoldemState.load();
+ HoldemState.load();
+
+ assert.equal(told, 1);
+});
+
+test("no room to keep the copy is not a crash", () => {
+ // Storage that refuses writes is exactly the storage most likely to hand back a
+ // half-written value in the first place.
+ withStored("{not json", { readOnly: true });
+
+ let state;
+ assert.doesNotThrow(() => { state = HoldemState.load(); });
+ assert.ok(Array.isArray(state.seats));
+ assert.equal(HoldemState.loadFailed, true);
+});
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 4ecee0d..27d2d03 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -180,6 +180,12 @@ const LayoutAudit = {
collapsedColumns() {
const offenders = [];
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
+ // Nothing inside something the page has hidden. A hidden element's children
+ // all measure zero wide, so the standings header — which is hidden along
+ // with its table until somebody has bought in — was reported as three
+ // collapsed columns on every empty table. A column nobody can see hasn't
+ // collapsed; it isn't there.
+ if (row.closest("[hidden]")) return;
if (getComputedStyle(row).display !== "grid") return;
[...row.children].forEach(cell => {
if (getComputedStyle(cell).display === "none") return;
From 4cc8faf8cbb6f0e5a03e00b4552059e112fb8bb0 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 18:35:27 -0600
Subject: [PATCH 20/55] Notice when the same table is open twice
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Opening the tool in a second tab -- tapping the bookmark again rather than hunting for
the tab you already had -- silently throws away whatever the first one is doing.
Each tab holds its own copy in memory and each save writes the whole table, so the
second one to act wins. Driven with two tabs on one profile:
tab A: deal, and Ann calls stored -> bettingOpen true, committed [0,1,2,2]
tab B: type a name in seat 5 stored -> bettingOpen false, committed [0,0,0,0]
The deal, the blinds and the call are gone, and neither tab said anything. That is the
same silent shape as the save and load failures either side of this change.
The browser already tells us: a storage event fires in every other tab when the key
changes, and never in the one that wrote. So a tab that sees its table move underneath
it now says so, once, with the way out attached:
toast: "This table is open in another tab. Whichever acts last wins." [Reload]
panel: "This table is open in another tab, and both are writing to it — whichever
acts last wins, so work done in the other one can vanish. Close one, then
reload this tab to pick up the table as it stands."
Watched rather than merged, deliberately. Replacing the table under a dealer's hands
in the middle of a hand would be its own hazard, and reloading is the only resolution
that does not throw somebody's work away -- so it is offered rather than taken.
6 tests (269 total): a write from elsewhere is noticed, another app's key and our own
recovery key are not our business, ten writes produce one warning, watching twice
listens once, no listener is not an error, and no window at all is not an error.
Driven with two live tabs: both quiet at first, A deals and B warns with the reload
offer, A stays quiet about its own writes, two more actions in A add no further toasts
in B, and reloading B picks up A's table -- bettingOpen true, committed [2,1,2,2] --
with the warning cleared. Audit and contrast clean, no console errors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/main.js | 11 ++
.../wwwroot/js/casino/holdem/render.js | 15 ++-
.../wwwroot/js/casino/holdem/state.js | 24 +++++
tests/holdem/otherTab.test.js | 100 ++++++++++++++++++
4 files changed, 146 insertions(+), 4 deletions(-)
create mode 100644 tests/holdem/otherTab.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index a68e329..c30a69a 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -461,6 +461,17 @@ document.addEventListener("DOMContentLoaded", () => {
// The one time the browser refuses to save, say so out loud as well as leaving the
// standing warning in the Session panel. Set before the first render so a failure
// during start-up is caught too.
+ // Another tab writing to the same table. Said once, with the way out attached —
+ // reloading here adopts whatever that tab has done, which is the only resolution
+ // that doesn't throw somebody's work away.
+ HoldemState.onOtherTab = () => {
+ Toast.show(
+ "This table is open in another tab. Whichever acts last wins.",
+ { actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 });
+ Render.renderStorageWarning();
+ };
+ HoldemState.watchOtherTabs();
+
HoldemState.onSaveFailed = () => {
Toast.show(
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 068e8f0..6426453 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -761,13 +761,20 @@ const Render = {
renderStorageWarning() {
const host = document.getElementById("storageWarning");
if (!host) return;
- host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed;
+ host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive;
if (host.hidden) return;
- // Both can be true at once — a browser having trouble with storage tends to
- // have it in both directions — and the one that explains an empty table goes
- // first, because that's the question the dealer is standing there asking.
+ // Any of these can be true at once — a browser having trouble with storage
+ // tends to have it in both directions — and the one that explains an empty
+ // table goes first, because that's the question the dealer is standing there
+ // asking.
const lines = [];
+ if (HoldemState.otherTabActive) {
+ lines.push(
+ "This table is open in another tab, and both are writing to it — " +
+ "whichever acts last wins, so work done in the other one can vanish. " +
+ "Close one, then reload this tab to pick up the table as it stands.");
+ }
if (HoldemState.loadFailed) {
lines.push(
"The saved table couldn't be read, so this one started empty. " +
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 97d2484..e53cfbe 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -84,6 +84,30 @@ const HoldemState = {
// Where an unreadable save is kept instead of being written over.
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
+ // True once another tab has written to this table. Two tabs each hold their own
+ // copy in memory and each save writes the whole thing, so the second one to act
+ // silently throws the first one's work away — measured: tab A deals and takes a
+ // call, tab B renames a seat, and the deal, the blinds and the call are gone with
+ // nothing said in either tab.
+ //
+ // Watched rather than merged. Replacing the table under a dealer's hands mid-hand
+ // would be its own hazard; knowing there are two of them is what they need.
+ otherTabActive: false,
+ onOtherTab: null,
+
+ // The browser fires this in every *other* tab, never the one that wrote, so our
+ // own saves can't trip it.
+ watchOtherTabs() {
+ if (typeof window === "undefined" || this.tabWatcher) return;
+ this.tabWatcher = event => {
+ if (event.key !== this.STORAGE_KEY) return;
+ if (this.otherTabActive) return;
+ this.otherTabActive = true;
+ if (typeof this.onOtherTab === "function") this.onOtherTab();
+ };
+ window.addEventListener("storage", this.tabWatcher);
+ },
+
// True when there was a saved table and it couldn't be read. Same pair as
// saveFailed above, for the same reason: the UI has to be able to say so.
loadFailed: false,
diff --git a/tests/holdem/otherTab.test.js b/tests/holdem/otherTab.test.js
new file mode 100644
index 0000000..041d230
--- /dev/null
+++ b/tests/holdem/otherTab.test.js
@@ -0,0 +1,100 @@
+// Noticing that the same table is open twice.
+//
+// Two tabs each hold their own copy in memory and each save writes the whole thing,
+// so the second one to act throws the first one's work away. This doesn't merge them
+// — replacing a table under a dealer's hands mid-hand would be its own hazard — it
+// notices, which is what lets them decide.
+const test = require("node:test");
+const assert = require("node:assert");
+const { HoldemState } = require("./load");
+
+function withWindow() {
+ const listeners = [];
+ globalThis.window = {
+ addEventListener(type, fn) { if (type === "storage") listeners.push(fn); },
+ removeEventListener(type, fn) {
+ const i = listeners.indexOf(fn);
+ if (i >= 0) listeners.splice(i, 1);
+ },
+ };
+ return {
+ listeners,
+ fire(key) { listeners.forEach(fn => fn({ key })); },
+ };
+}
+
+test.beforeEach(() => {
+ HoldemState.otherTabActive = false;
+ HoldemState.onOtherTab = null;
+ HoldemState.tabWatcher = null;
+});
+
+test.after(() => {
+ delete globalThis.window;
+ HoldemState.otherTabActive = false;
+ HoldemState.onOtherTab = null;
+ HoldemState.tabWatcher = null;
+});
+
+test("a write to this table from elsewhere is noticed", () => {
+ const win = withWindow();
+ let told = 0;
+ HoldemState.onOtherTab = () => { told++; };
+ HoldemState.watchOtherTabs();
+
+ win.fire(HoldemState.STORAGE_KEY);
+
+ assert.equal(HoldemState.otherTabActive, true);
+ assert.equal(told, 1);
+});
+
+test("somebody else's storage key is not our business", () => {
+ const win = withWindow();
+ let told = 0;
+ HoldemState.onOtherTab = () => { told++; };
+ HoldemState.watchOtherTabs();
+
+ win.fire("some.other.app.state");
+ win.fire(HoldemState.RECOVERY_KEY);
+
+ assert.equal(HoldemState.otherTabActive, false);
+ assert.equal(told, 0);
+});
+
+test("told once, however many times the other tab acts", () => {
+ const win = withWindow();
+ let told = 0;
+ HoldemState.onOtherTab = () => { told++; };
+ HoldemState.watchOtherTabs();
+
+ for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY);
+
+ assert.equal(told, 1);
+});
+
+test("watching twice doesn't listen twice", () => {
+ const win = withWindow();
+ HoldemState.watchOtherTabs();
+ HoldemState.watchOtherTabs();
+ HoldemState.watchOtherTabs();
+
+ assert.equal(win.listeners.length, 1);
+});
+
+test("no listener set is not an error", () => {
+ const win = withWindow();
+ HoldemState.onOtherTab = null;
+ HoldemState.watchOtherTabs();
+
+ assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY));
+ assert.equal(HoldemState.otherTabActive, true);
+});
+
+test("no window at all is not an error", () => {
+ // The same modules are loaded in Node by the test harness.
+ delete globalThis.window;
+ HoldemState.tabWatcher = null;
+
+ assert.doesNotThrow(() => HoldemState.watchOtherTabs());
+ assert.equal(HoldemState.otherTabActive, false);
+});
From 95fd46ee99907c24efc5c76f64b833c40aef6bcd Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 18:54:52 -0600
Subject: [PATCH 21/55] Take the broken wordmark off the felt
The felt carried "TEXAS HOLD'EM" across its middle, and the pot badge sat on top of
it. Measured at every seat count from two to ten, the middle 20% of the word was
covered, so the table read
TEXAS LD'EM
That is not a watermark, it is a rendering fault, and it looked like one.
I tried to move it before removing it. Four placements, measured across six viewports
and two seat counts each, as the share of the word covered by a seat, a bet chip or
the pot badge:
desktop tablet phone phone landscape
1280x900 768x1024 390x844 360x640 844x390
centred (before) 20% 20% 20% 20% 20%
30% down, full size 0-7% 0-12% 43-60% 48-70% 20-23%
20% down, smaller 0% 0% 40-67% 53-70% 40%
78% down, smaller 0% 0% 77-97% 90-100%* 27-37%
* and off the oval entirely at 360px
Desktop and tablet can be made clean. A phone cannot: below 900px the felt is square,
and a square felt with six to nine seats has no clear horizontal band wide enough for
a word. The best phone result of any placement was 40% covered.
So it is gone rather than special-cased per device for a decoration. The felt still has
its gradient, its rim, the inner gold line and its shadow to be a table with, and the
page header says "Texas Hold'em Dealer" a few inches above it.
The reasoning and the numbers are left in the CSS where the element used to be, so
whoever thinks of adding one back finds out what happened first.
269 tests still passing. Seven viewports: contrast clean, layout audit clean everywhere
but the landscape phone already known from #63, no console errors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 1 -
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 31 +++++++------------
2 files changed, 12 insertions(+), 20 deletions(-)
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 65d9fdb..56ea9ed 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -25,7 +25,6 @@
-
Texas Hold'em
Pot$0
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 1d7deca..87379f9 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -357,21 +357,18 @@ button,
justify-content: center;
}
-#tableWatermark {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- color: rgba(255, 255, 255, 0.06);
- font-size: 2.4rem;
- font-weight: 700;
- letter-spacing: 0.15em;
- text-transform: uppercase;
- text-align: center;
- pointer-events: none;
- user-select: none;
-}
+/* The felt used to carry a "TEXAS HOLD'EM" wordmark across its middle. It came out
+ from behind the pot badge in two pieces — measured at every seat count from two to
+ ten, the middle 20% of it was covered, so the felt read "TEXAS LD'EM" — and no
+ placement fixed that everywhere. Thirty per cent down cleared the badge on a desktop
+ and landed on the seats of a phone's square felt (43-70% covered); a smaller mark at
+ 20% or 78% was clean on desktop and tablet and no better on a phone; the bottom one
+ fell off the oval entirely at 360px. A square felt with six to nine seats has no
+ clear band wide enough for a word.
+
+ So it's gone. A watermark that looks broken is worse than no watermark, and the felt
+ has a gradient, a rim, an inner gold line and a shadow to be a table with — plus a
+ header saying "Texas Hold'em Dealer" a few inches above it. */
#potBadge {
position: relative;
@@ -2845,10 +2842,6 @@ body.hasActionBar #toastHost {
padding: 0.25rem 0.3rem;
}
- #tableWatermark {
- font-size: 1.4rem;
- }
-
#potBadge {
padding: 0.5rem 1.1rem;
}
From f0cfd24647e3bfb6970ef220cb7a2c381569c6f2 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 19:10:06 -0600
Subject: [PATCH 22/55] Make the seats that are out of the hand readable again
A folded player's stack measured 2.22:1.
The contrast audit has been reporting clean for twenty rounds, and it never looked at
these seats: the check skipped anything with opacity below 0.99 as "faded on purpose",
and folded and sat-out seats faded their whole box -- 0.5 and 0.58. Opacity on a box
fades the text you still have to read along with the box you wanted to push back.
Measured off rendered pixels, 11.5px text, where AA asks 4.5:1:
name stack
live 13.28 4.75
folded 4.75 2.22
sitting out 7.00 3.22
A folded player's stack is not decoration. It is what they will have next hand and the
answer to "how much has Ben got?", and at 2.22 it was barely there.
So the box recedes and the text does not: a flatter, darker background and a muted
border, with name and stack colours that measure.
name stack
live 13.28 4.75 (unchanged)
folded 7.60 4.55
sitting out 7.60 4.71
Taking the fade away took the signal with it, which the screenshot showed and the
numbers did not: a sat-out seat became almost indistinguishable from a live one. A
folded seat still says so twice, with a red border and a line through the name, but
sitting out had only faintness. It wears a dashed edge now -- the same thing an empty
chair wears, which is right, because neither is in this hand, and the name and stack
tell the two apart.
The same fault, found once the audit stopped hiding it
The key hints on the action bar are 9.6px and were measuring 2.97 to 3.33 against
their buttons -- a fade chosen for looks pushing text a dealer is meant to read under
the threshold, exactly like the seats. They are 4.93 to 7.63 now, still quieter than
the labels they sit beside.
And the audit no longer passes over faded text in silence. Something invisible is still
exempt, since there is nothing to read at zero opacity, but anything in between is
reported as unmeasured -- a number nobody checked is not the same as a number that is
fine. It now lists five: the four key caps and the disabled Export CSV button, which is
disabled and therefore genuinely exempt.
269 tests still passing. Contrast offenders zero, layout audit clean, no console
errors; the folded seat's red line-through and the sat-out seat's dashed edge both
confirmed in cropped screenshots rather than assumed.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 53 +++++++++++++++++++--
tests/holdem/uiAudit.js | 26 +++++++++-
2 files changed, 74 insertions(+), 5 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 87379f9..57b44ee 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -510,13 +510,53 @@ button,
/* Sitting out, not gone: still a person the dealer may need to deal back in, so
the name has to stay readable rather than fading almost to the felt. */
+/* Out of the hand, still readable.
+
+ Both of these used to fade the whole seat — opacity 0.5 folded, 0.58 sitting out —
+ and opacity on a box fades the text you still have to read along with the box you
+ wanted to push back. Measured off the rendered pixels at 11.5px text, where AA asks
+ for 4.5:1:
+
+ live name 13.28 stack 4.75
+ folded name 4.75 stack 2.22
+ sat out name 7.00 stack 3.22
+
+ A folded player's stack is not decoration. It's what they'll have next hand and the
+ answer to "how much has Ben got?", and at 2.22:1 it was barely there.
+
+ So the box recedes and the text doesn't: a flatter, darker background and a muted
+ border say "not in this hand", while the name and the stack keep colours that
+ measure. The folded seat keeps its red border and the line through the name, which
+ is the part that actually names the state. */
+.seat.seatInactive,
+.seat.seatFolded {
+ background: linear-gradient(160deg, #232323, #171717);
+ border-color: rgba(255, 255, 255, 0.06);
+}
+
+/* A folded seat already says so twice — a red border and a line through the name — but
+ a seat that's sitting out said it only by being faint, and taking the faintness away
+ took the whole signal with it. A dashed edge is the same thing an empty chair wears,
+ which is right: neither is in this hand. The name and the stack tell them apart. */
.seat.seatInactive {
- opacity: 0.58;
+ border-style: dashed;
+ border-color: rgba(255, 255, 255, 0.22);
+}
+
+.seat.seatInactive .seatPuckName,
+.seat.seatFolded .seatPuckName {
+ color: #b0b0b0;
+}
+
+.seat.seatInactive .seatPuckStack,
+.seat.seatFolded .seatPuckStack,
+.seat.seatInactive .seatPuckTotal,
+.seat.seatFolded .seatPuckTotal {
+ color: #8f8f8f;
}
.seat.seatFolded {
border-color: rgba(224, 123, 123, 0.4);
- opacity: 0.5;
}
.seat.seatFolded .seatPuckName {
@@ -2169,11 +2209,12 @@ body.hasActionBar #holdemSidebar {
/* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */
.actionUndoKey {
+ /* See .actionKey below for why these are not fainter. */
margin-left: 0.4rem;
padding: 0 0.25rem;
border-radius: 4px;
border: 1px solid currentColor;
- opacity: 0.55;
+ opacity: 0.75;
font-family: inherit;
font-size: 0.6rem;
line-height: 1.4;
@@ -2181,6 +2222,10 @@ body.hasActionBar #holdemSidebar {
pointer-events: none;
}
+/* A key hint is small — 9.6px — so AA asks 4.5:1 of it, and at the opacity these used
+ to carry they measured 2.97 to 3.33 against their buttons. That's the same fault as
+ the folded seats above: a fade chosen for looks pushing text a dealer is meant to
+ read under the threshold. They're still quieter than the label they sit beside. */
.actionKey {
position: absolute;
top: 5px;
@@ -2188,7 +2233,7 @@ body.hasActionBar #holdemSidebar {
padding: 0 0.25rem;
border-radius: 4px;
border: 1px solid currentColor;
- opacity: 0.4;
+ opacity: 0.75;
font-family: inherit;
font-size: 0.6rem;
line-height: 1.4;
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 27d2d03..98b270d 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -349,6 +349,16 @@ const LayoutAudit = {
});
},
+ // Every element between this one and the document, so an opacity set on a
+ // container can be seen from the text inside it.
+ *ancestors(el) {
+ let node = el.parentElement;
+ while (node) {
+ yield node;
+ node = node.parentElement;
+ }
+ },
+
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];
@@ -365,7 +375,21 @@ const LayoutAudit = {
if (rect.width === 0 || rect.height === 0) return;
const style = getComputedStyle(el);
if (style.visibility === "hidden" || style.display === "none") return;
- if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
+ // Faded text used to be skipped outright as "faded on purpose", and that
+ // exemption hid a real fault: the folded and sat-out seats dimmed their
+ // whole box with opacity, so a folded player's stack measured 2.22:1 at
+ // 11.5px and this check never looked at it. Something invisible is still
+ // exempt — there's nothing to read at zero — but anything in between is
+ // reported as unmeasured rather than passed over in silence, because a
+ // number nobody checked is not the same as a number that's fine.
+ const opacity = parseFloat(style.opacity);
+ if (opacity <= 0.02) return;
+ const faded = opacity < 0.99 ||
+ [...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
+ if (faded) {
+ unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
+ return;
+ }
// Decoration is exempt by definition: the watermark on the felt is meant
// to be barely there, and holding it to a reading threshold would mean
// making it not a watermark. Marked aria-hidden so screen readers skip it
From d297c1a2c991f5d17e006bc1a121014e1f212517 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 19:21:06 -0600
Subject: [PATCH 23/55] Close two blind spots in the contrast check
Last change found a folded player's stack sitting at 2.22:1, hidden for twenty rounds
behind an exemption. So I went looking for the other exemptions rather than waiting for
the next one to surface. There were two, and neither is hiding a live fault today --
which is worth saying plainly, because it means this is a fix to the instrument and not
to the tool.
Leaf elements only
if (el.children.length > 0) return; // leaf text only
An element's own text stopped being measured the moment a child was added to it. The
undo control went that way two changes ago, the day a was appended to it: its
label became invisible to this check with no sign that anything had changed. Now it
measures an element's own text nodes and leaves the descendants to their own turn,
against their own colours.
aria-hidden as a contrast exemption
The bets layer is aria-hidden because the amounts on the chips are already announced by
the seat and the action bar, and repeating them would be noise. That is a good reason
to hide them from a screen reader and no reason at all to stop checking whether a
sighted dealer can read them -- and the chips are exactly what the felt is read for.
Two different questions were being answered by one attribute.
Nothing is exempt now. If something genuinely decorative turns up, it should say so in
its own words rather than borrowing an attribute that means something else. The
watermark that the exemption was written for is gone as of the change before last.
What the widened check finds
Nothing. Zero offenders at six viewports across touch and pointer contexts, and with
the sidebar panels and the rulings sheet open. Measured from rendered pixels, the two
things that had been invisible are comfortable: the bet chip amounts read 12.65 to
13.74:1 and the undo label 8.06:1.
So the fix is proved by injection instead. Colouring the bet chip amounts #3a3a3a now
reports three offenders at 1.80:1, and colouring the undo control #2f2f2f reports it at
1.13:1. Neither was reported before.
269 tests still passing, no console errors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
tests/holdem/uiAudit.js | 31 ++++++++++++++++++++++++-------
1 file changed, 24 insertions(+), 7 deletions(-)
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 98b270d..0c1bca8 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -368,8 +368,18 @@ const LayoutAudit = {
: [...document.querySelectorAll("*")];
nodes.forEach(el => {
- if (el.children.length > 0) return; // leaf text only
- const text = (el.textContent || "").trim();
+ // An element's *own* text, not its descendants' — they get measured on
+ // their own turn, against their own colour.
+ //
+ // This used to be "leaf elements only", which quietly stopped measuring
+ // anything the moment a child was added to it. The undo control went that
+ // way the day a was appended to it: its label became invisible to
+ // this check, with no sign that anything had changed.
+ const text = [...el.childNodes]
+ .filter(node => node.nodeType === 3)
+ .map(node => node.textContent)
+ .join(" ")
+ .trim();
if (!text) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
@@ -390,11 +400,18 @@ const LayoutAudit = {
unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
return;
}
- // Decoration is exempt by definition: the watermark on the felt is meant
- // to be barely there, and holding it to a reading threshold would mean
- // making it not a watermark. Marked aria-hidden so screen readers skip it
- // too, which is the same claim expressed where it also does some good.
- if (el.closest("[aria-hidden='true']")) return;
+ // aria-hidden used to exempt text here, on the grounds that decoration
+ // marked for screen readers was decoration for everyone. Those are two
+ // different questions and the answer differs: the bet chips on the felt are
+ // aria-hidden because their amounts are already announced by the seat and
+ // the action bar, and repeating them would be noise — but they are exactly
+ // what a sighted dealer reads the felt for, and this check had stopped
+ // looking at them. Hiding something from one audience is not a claim about
+ // whether the other audience can read it.
+ //
+ // Nothing is exempt now. If something genuinely decorative turns up, it
+ // should say so in its own words rather than borrowing an attribute that
+ // means something else.
const fgRaw = this.parseColour(style.color);
if (!fgRaw) return;
From 65f201cdfd2d9cce19d1ddc8835cc8ae39884429 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 19:36:29 -0600
Subject: [PATCH 24/55] Show each stack as the chips that make it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The chip legend has only ever read chips *into* an amount -- counting what is in front
of somebody. Colouring up at the end of a level is the same question backwards, and it
is the one the dealer answers with a rack in one hand: $205 is two hundreds and a five.
So is counting a stack out for a player who is leaving.
The arithmetic already existed. Payouts have been shown in chips since #73; this points
the same function at the stacks.
Stacks in chips
Ann — $205 2×$100, 1×$5
Ben — $1,337 2×$500, 3×$100, 1×$25, 2×$5, 2×$1
Cal — $40 1×$25, 3×$5
Eve — $88 3×$25, 2×$5, 3×$1
Fay — $500 1×$500
Sits under the chip legend, because that is where the denominations it works from are
set, and it only appears when there is both a legend to work from and somebody with
chips. A table that never set its chip colours up gets nothing, which is what it had
before. A seat on zero is left out rather than listed as nothing.
Driven at five sizes across touch and pointer contexts, with a 26-character name and a
$1,337,000 stack: no row spills, no page overflow, contrast clean, layout audit clean
but for the landscape phone already known from #63, no console errors. Every breakdown
checked by hand against its total -- 500+200+75+2 = 777 and so on.
The breakdown itself is covered by the twelve tests from #73; what is new here is which
seats are listed, which the browser run covers: with a legend and stacks it lists five
of six seats, with the legend emptied it disappears, and with every stack zeroed it
disappears. 269 tests still passing.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 6 +++
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 24 +++++++++++
.../wwwroot/js/casino/holdem/render.js | 43 +++++++++++++++++++
3 files changed, 73 insertions(+)
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 56ea9ed..7b6ada2 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -153,6 +153,12 @@
+ Add chip color
Give each chip color a value, then count chips on a seat instead of typing a total.
+
+
+
Stacks in chips
+
+
The fewest chips that make each stack — for colouring up, or counting one out.
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 57b44ee..cbb7273 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1145,6 +1145,30 @@ button,
line-height: 1.4;
}
+/* Each stack and the chips that make it, laid out like the shortcut legend below —
+ the thing being named on one line, the answer under it, so a dealer with a rack in
+ one hand reads down rather than across. */
+.colourUpList {
+ margin: 0.2rem 0 0;
+}
+
+.colourUpWho {
+ color: #ddd;
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.colourUpChips {
+ margin: 0 0 0.45rem;
+ color: #9fb8a6;
+ font-size: 0.74rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.colourUpChips:last-child {
+ margin-bottom: 0;
+}
+
/* The shortcut legend. A touch device has no keys to press, so it doesn't get a panel
telling it about them — the same pointer question the touch-target rules ask. */
@media (pointer: coarse) {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 6426453..87838cf 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -667,6 +667,49 @@ const Render = {
list.appendChild(row);
});
+
+ this.renderColourUp();
+ },
+
+ // Each stack as the chips that make it.
+ //
+ // The legend has only ever read chips *into* an amount — counting what's in front
+ // of somebody. Colouring up at the end of a level is the same question backwards,
+ // and it's the one the dealer has to answer with a rack in one hand: $205 is four
+ // fifties and a five. So is counting a stack out for a player who's leaving.
+ //
+ // Only when there's a legend to work from and a stack to break down; a table that
+ // never set its chip colours up gets nothing, which is what it had before.
+ renderColourUp() {
+ const host = document.getElementById("colourUp");
+ const list = document.getElementById("colourUpList");
+ if (!host || !list) return;
+
+ const legend = this.state.chipLegend || [];
+ const rows = this.state.seats
+ .map((player, index) => ({ player, index }))
+ .filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0)
+ .map(({ player, index }) => ({
+ name: HoldemState.displayName(player, index),
+ stack: Betting.round(Number(player.stack) || 0),
+ chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend),
+ }))
+ .filter(row => row.chips);
+
+ host.hidden = rows.length === 0;
+ if (host.hidden) return;
+
+ list.innerHTML = "";
+ rows.forEach(row => {
+ const term = document.createElement("dt");
+ term.className = "colourUpWho";
+ term.textContent = `${row.name} — ${Money.format(row.stack)}`;
+ const detail = document.createElement("dd");
+ detail.className = "colourUpChips";
+ detail.textContent = row.chips;
+ list.appendChild(term);
+ list.appendChild(detail);
+ });
},
// ---- Pot breakdown ----
From ab92259e3e06d291180a2c9cd67814638d8e0075 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 19:52:20 -0600
Subject: [PATCH 25/55] Put the dealer's own notes in the file they hand over
The CSV export carries the hands, the standings and the settle-up. It carried neither
of the two places a dealer types context.
Each seat can hold custom fields -- "IOU: $20 to Ann", "Seat rent: paid to 2am",
"Venmo: @ann" -- and the table has a notes box for the rest: side bets, a high-hand
promo, who owes the box from last week. Both are typed precisely because they matter to
settling up, and both were visible only inside the tool. The one place the context was
written down was the one place it could not leave.
Now the standings rows end with a Notes column carrying that seat's fields, and the
table's notes go in a section of their own at the end.
Player,Paid In,Won,Net,Bought In,Chips Now,Up/Down,Notes
Ann,20.00,60.00,40.00,200.00,150.00,-50.00,Seat rent: paid to 2am; Venmo: @ann
Ben,20.00,0.00,-20.00,200.00,200.00,0.00,IOU: $20 to Ann
Cal,20.00,0.00,-20.00,200.00,250.00,50.00,
Table Notes
"High-hand promo until midnight.
Mo owes the box $15, from last week."
Notes go last so the money columns keep the shape a spreadsheet expects, and the
multi-line note is quoted as one cell rather than becoming rows that read as more
players.
An existing test caught this, correctly
"every money cell parses as a number" asserted that every column after the name was
numeric, and a Notes column is text by definition. The invariant it protects is real --
the file exists so somebody can total a column -- so it now names the six money columns
and checks those, rather than "everything after the first". That is the assertion it
always meant.
9 tests (278 total): a seat's fields ride along, the money columns keep their order, a
seat with nothing written gets an empty cell rather than the word undefined, a blank
field does not leave a stray colon, a multi-line note survives as one cell, no notes
means no section rather than an empty heading, and asking for the standings does not
change the seats.
Driven through the real Export CSV button, not just the function: the downloaded file
carries both seats' notes and the table's. Audit and contrast clean, no console errors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../js/casino/holdem/handHistoryExport.js | 25 ++++-
.../wwwroot/js/casino/holdem/main.js | 3 +-
.../wwwroot/js/casino/holdem/state.js | 9 ++
tests/holdem/exportNotes.test.js | 97 +++++++++++++++++++
tests/holdem/money.test.js | 21 +++-
5 files changed, 147 insertions(+), 8 deletions(-)
create mode 100644 tests/holdem/exportNotes.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
index 4a3e4db..0257d3f 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
@@ -19,7 +19,7 @@ const HandHistoryExport = {
// text, so the one thing anybody opens this file to do — total a column and see
// who owes what — doesn't work without cleaning every cell first. The commas were
// quoted correctly, so the file parsed; it just wouldn't add up.
- toCsv(handHistory, standings = []) {
+ toCsv(handHistory, standings = [], { notes = "" } = {}) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
@@ -41,7 +41,12 @@ const HandHistoryExport = {
rows.push(["Session Standings"]);
// Bought in and chips now are what a cash-out is settled from; paid in and
// won describe the hands. Both belong in the file the dealer hands over.
- rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
+ //
+ // Notes go last so the money columns keep the shape a spreadsheet expects.
+ // "IOU: $20 to Ann" is exactly the kind of thing that has to be in the file
+ // somebody settles up from, and it was only ever visible inside that seat's
+ // own editor.
+ rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
standings.forEach(row => {
rows.push([
row.name,
@@ -51,6 +56,7 @@ const HandHistoryExport = {
Money.forSheet(row.boughtIn || 0),
Money.forSheet(row.chipsNow || 0),
Money.forSheet(row.cashPosition || 0),
+ row.notes || "",
]);
});
@@ -76,6 +82,17 @@ const HandHistoryExport = {
}
}
+ // The table's own notes — side bets, IOUs, the promo running until midnight.
+ // The dealer typed them because they matter to the night, and the export is the
+ // thing that outlives the browser tab; leaving them behind meant the one place
+ // the context was written down was the one place it couldn't be handed over.
+ // csvField quotes the line breaks, so a multi-line note survives as one cell.
+ if (notes && notes.trim()) {
+ rows.push([]);
+ rows.push(["Table Notes"]);
+ rows.push([notes.trim()]);
+ }
+
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
},
@@ -86,8 +103,8 @@ const HandHistoryExport = {
return `holdem-hand-history-${stamp}.csv`;
},
- download(handHistory, standings = []) {
- const csv = this.toCsv(handHistory, standings);
+ download(handHistory, standings = [], options = {}) {
+ const csv = this.toCsv(handHistory, standings, options);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index c30a69a..d2a1da3 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -580,7 +580,8 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
- HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
+ HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
+ { notes: state.notes });
});
// The load happens on the first line of this file, long before there's a Toast to
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index e53cfbe..4228ba4 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -532,6 +532,14 @@ const HoldemState = {
// window is almost always.
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
+ // Whatever the dealer wrote against this seat — "IOU: $20 to Ann",
+ // "Venmo: @ann". It's carried here so the export can hand it over with
+ // the money it belongs to; the standings panel itself doesn't show it.
+ const notes = (seat && Array.isArray(seat.customFields) ? seat.customFields : [])
+ .filter(field => (field.label || "").trim() || (field.value || "").trim())
+ .map(field => `${(field.label || "").trim()}: ${(field.value || "").trim()}`.replace(/^: | :$/, ""))
+ .join("; ");
+
return {
...t,
in: Money.round(t.in),
@@ -540,6 +548,7 @@ const HoldemState = {
boughtIn,
chipsNow,
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
+ notes,
};
})
.sort((a, b) => b.net - a.net);
diff --git a/tests/holdem/exportNotes.test.js b/tests/holdem/exportNotes.test.js
new file mode 100644
index 0000000..a0769a6
--- /dev/null
+++ b/tests/holdem/exportNotes.test.js
@@ -0,0 +1,97 @@
+// The things the dealer wrote down, in the file they hand over.
+//
+// The export already carried the hands, the standings and the settle-up. It didn't
+// carry either of the two places a dealer types context — the per-seat fields and the
+// table's own notes — so the one place an IOU was written down was the one place it
+// couldn't leave.
+const test = require("node:test");
+const assert = require("node:assert");
+const { HandHistoryExport, HoldemState, makeTable } = require("./load");
+
+const tableWithNotes = () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
+ ["Ann", "Ben", "Cal"].forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].buyIns = 200;
+ });
+ state.seats[0].customFields = [
+ { label: "Seat rent", value: "paid to 2am" },
+ { label: "Venmo", value: "@ann" },
+ ];
+ state.seats[1].customFields = [{ label: "IOU", value: "$20 to Ann" }];
+ return state;
+};
+
+test("a seat's own fields ride along with its standings row", () => {
+ const state = tableWithNotes();
+ const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
+
+ assert.match(csv, /Seat rent: paid to 2am; Venmo: @ann/);
+ assert.match(csv, /IOU: \$20 to Ann/);
+});
+
+test("the money columns keep their order, with notes on the end", () => {
+ const state = tableWithNotes();
+ const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
+
+ assert.match(csv, /Player,Paid In,Won,Net,Bought In,Chips Now,Up\/Down,Notes/);
+});
+
+test("a seat with nothing written against it gets an empty cell, not the word undefined", () => {
+ const state = tableWithNotes();
+ const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
+ const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
+
+ assert.ok(calRow, "Cal should have a row");
+ assert.ok(calRow.endsWith(","), `expected an empty last cell, got ${calRow}`);
+ assert.ok(!/undefined|null/.test(calRow));
+});
+
+test("a blank label or value doesn't produce a stray colon", () => {
+ const state = tableWithNotes();
+ state.seats[2].customFields = [{ label: "Note", value: "" }, { label: "", value: "" }];
+ const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
+ const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
+
+ assert.match(calRow, /Note/);
+ assert.ok(!/;\s*;/.test(calRow), "an entirely blank field shouldn't take a slot");
+});
+
+test("the table's notes are in the file", () => {
+ const state = tableWithNotes();
+ state.notes = "High-hand promo until midnight.";
+ const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: state.notes });
+
+ assert.match(csv, /Table Notes/);
+ assert.match(csv, /High-hand promo until midnight\./);
+});
+
+test("a multi-line note survives as one cell", () => {
+ // csvField quotes anything with a line break in it, so the note doesn't turn into
+ // rows a spreadsheet reads as more players.
+ const state = tableWithNotes();
+ const notes = "Line one\nLine two, with a comma\nLine \"three\"";
+ const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes });
+
+ assert.match(csv, /"Line one\nLine two, with a comma\nLine ""three"""/);
+});
+
+test("no notes means no section, not an empty heading", () => {
+ const state = tableWithNotes();
+ const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: " " });
+
+ assert.ok(!/Table Notes/.test(csv));
+});
+
+test("the export still works with no standings at all", () => {
+ assert.doesNotThrow(() => HandHistoryExport.toCsv([], [], { notes: "just a note" }));
+ const csv = HandHistoryExport.toCsv([], [], { notes: "just a note" });
+ assert.match(csv, /Table Notes/);
+});
+
+test("asking for the standings doesn't change the seats", () => {
+ const state = tableWithNotes();
+ const before = JSON.stringify(state.seats);
+ HoldemState.sessionStandings(state);
+ assert.equal(JSON.stringify(state.seats), before);
+});
diff --git a/tests/holdem/money.test.js b/tests/holdem/money.test.js
index 2e2da89..a906e41 100644
--- a/tests/holdem/money.test.js
+++ b/tests/holdem/money.test.js
@@ -96,11 +96,26 @@ describe("the exported CSV", () => {
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
});
- // Standings rows: every column after the name has to be numeric.
+ // Standings rows: the money columns have to be numeric. Named rather than
+ // "everything after the first", which is what this used to say — the row now
+ // ends with a Notes column carrying whatever the dealer typed against the seat,
+ // and that is text by definition. The point of this test is that a column
+ // somebody totals adds up, so it checks the columns somebody totals.
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
+ const header = splitRow(lines[standingsStart]);
+ const moneyColumns = ["Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]
+ .map(name => {
+ const index = header.indexOf(name);
+ assert.ok(index > 0, `the standings header should still have a ${name} column`);
+ return index;
+ });
+
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
- splitRow(line).slice(1).forEach(cell => {
- assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
+ const cells = splitRow(line);
+ moneyColumns.forEach(index => {
+ const cell = cells[index];
+ assert.ok(cell !== "" && !Number.isNaN(Number(cell)),
+ `standings cell "${cell}" in column ${header[index]} isn't a number`);
});
});
});
From cb057b5159871d18e440e1a9d4666b9a940cf969 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 20:06:42 -0600
Subject: [PATCH 26/55] Stop the think-clock claiming things it can't know
The clock beside the acting player told two lies, in opposite directions.
Refresh mid-hand and it restarts at 0:00. The clock lives in memory, so a reload wipes
it, and the bar then says the player has just started thinking when they might have
been in the tank for two minutes. It is the one number in the bar whose whole job is to
tell a dealer when to nudge somebody, and a refresh reset it to "no need".
It cannot know that time -- the action moved before this session existed -- so it does
not guess. It shows a dash until the action next moves, and says why if you hover:
"The action was already on this seat when the page loaded, so there's nothing to
time from yet. The clock starts when the action next moves."
The moment the action moves it is measuring something it saw, and it counts from zero
again.
And past an hour it read "65:03". A table left open on a hand really does get there --
the state survives a refresh by design -- and sixty-five minutes written as though it
were minutes and seconds is a number the eye gets wrong. It reads 1:05:03 now.
Checked in the browser rather than reasoned about: dealt in this session it counts 0:00
then 0:02; refreshed mid-hand it shows the dash and keeps showing it rather than
drifting up from an invented zero; one action later it is back to a real 0:00; and with
the clock wound forward it reads 1:05:04 with the slow-tank styling still applied.
5 tests (283 total) on the formatting, including that minutes are padded once there are
hours and not before, and that a day reads 25:00:00 rather than wrapping.
Also checked and correct already: a backgrounded tab does not lose time -- four minutes
away reads 4:01 on return, because the clock is a timestamp difference rather than a
count of ticks -- and the timer disappears entirely once the hand is over.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/actionBar.js | 39 ++++++++++++++++++-
tests/holdem/tankClock.test.js | 37 ++++++++++++++++++
2 files changed, 74 insertions(+), 2 deletions(-)
create mode 100644 tests/holdem/tankClock.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 1998e6e..0e5d969 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -11,6 +11,11 @@ const ActionBar = {
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
+ // Whoever was already on the clock when this page loaded — see tankUnknownFor.
+ this.tankUnknownFor = state.bettingOpen &&
+ state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
+ ? state.actingSeatIndex
+ : null;
this.watchHeight();
this.render();
},
@@ -207,6 +212,14 @@ const ActionBar = {
tankStartedAt: null,
tankInterval: null,
+ // The seat that was already being asked to act when this page loaded, if there was
+ // one. Its clock is unknowable: the action moved to them before this session
+ // started — maybe two minutes ago, maybe last night — and starting from zero would
+ // have the bar say they had just begun thinking. Which it did, every time a dealer
+ // refreshed mid-hand. Cleared the moment the action moves, because from then on the
+ // clock is measuring something it actually saw.
+ tankUnknownFor: null,
+
// Restarts only when the action actually moves — a re-render for any other reason
// (a chip edit, a name change) must not put a thinking player back to zero.
syncTankTimer() {
@@ -220,6 +233,8 @@ const ActionBar = {
}
if (this.tankSeatIndex !== index) {
+ // Once the action has moved under our own eyes, nothing is unknown any more.
+ if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
}
@@ -243,13 +258,33 @@ const ActionBar = {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
+ if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
+ el.textContent = "—";
+ el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
+ el.classList.remove("tankNudge", "tankSlow");
+ return;
+ }
+
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
- const mins = Math.floor(seconds / 60);
- el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
+ el.title = "How long this player has been thinking";
+ el.textContent = this.formatTank(seconds);
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
},
+ // "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
+ // open on a hand does reach that, and sixty-five minutes written as though it were
+ // a minutes-and-seconds reading is a number the eye gets wrong.
+ formatTank(seconds) {
+ const pad = value => String(value).padStart(2, "0");
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const rest = seconds % 60;
+ return hours > 0
+ ? `${hours}:${pad(minutes)}:${pad(rest)}`
+ : `${minutes}:${pad(rest)}`;
+ },
+
// Shown only once someone is all-in and the money has actually split — a single
// undivided pot needs no announcement, and a line that's always there stops being
// read.
diff --git a/tests/holdem/tankClock.test.js b/tests/holdem/tankClock.test.js
new file mode 100644
index 0000000..1dccacf
--- /dev/null
+++ b/tests/holdem/tankClock.test.js
@@ -0,0 +1,37 @@
+// How the think-clock reads.
+const test = require("node:test");
+const assert = require("node:assert");
+const path = require("path");
+
+const ActionBar = require(path.join(
+ __dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"));
+
+test("seconds under a minute", () => {
+ assert.equal(ActionBar.formatTank(0), "0:00");
+ assert.equal(ActionBar.formatTank(7), "0:07");
+ assert.equal(ActionBar.formatTank(59), "0:59");
+});
+
+test("minutes and seconds", () => {
+ assert.equal(ActionBar.formatTank(60), "1:00");
+ assert.equal(ActionBar.formatTank(241), "4:01");
+ assert.equal(ActionBar.formatTank(3599), "59:59");
+});
+
+test("past the hour it says the hour", () => {
+ // A table left open on a hand reaches this, and "65:03" is a number the eye reads
+ // as minutes and seconds when it isn't.
+ assert.equal(ActionBar.formatTank(3600), "1:00:00");
+ assert.equal(ActionBar.formatTank(3903), "1:05:03");
+ assert.equal(ActionBar.formatTank(45296), "12:34:56");
+});
+
+test("the minutes are padded once there are hours, and not before", () => {
+ // "1:5:03" would be wrong; "05:03" on its own would be odd.
+ assert.equal(ActionBar.formatTank(3903), "1:05:03");
+ assert.equal(ActionBar.formatTank(303), "5:03");
+});
+
+test("a day is still a readable number rather than a wrapped one", () => {
+ assert.equal(ActionBar.formatTank(90000), "25:00:00");
+});
From 3149b48c144f81bd4b7e7a00c04e0771f05aac40 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 20:27:46 -0600
Subject: [PATCH 27/55] Remember the pots the dealer throws away, so the bank
check stops blaming them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Starting the next hand with money still in the middle destroys it. The tool asks
first, and says afterwards how much went. Nothing wrote it down, though, so when
the bank check came round at cash-out it saw only that chips were gone:
$5 more cash than chips in play. Someone has paid for chips they haven't
been given.
and settle-up told the dealer to fix it before anyone cashed out. Both were
accusing the dealer of a miscount for money the tool itself had removed, at the
dealer's instruction, minutes earlier — the one kind of shortfall that isn't a
mistake, reported as the one kind that is.
Discards are now recorded on the table as they happen (undoably, with the rest
of the Next Hand step), the reconciliation counts them alongside the chips, and
the panel shows a "Taken off the table" line so the number is visible rather
than merely subtracted. A genuine shortfall on top of a discard still reports,
at its true size — the discard accounts for its own $5 and not a cent more.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 4 +
.../wwwroot/js/casino/holdem/main.js | 6 ++
.../wwwroot/js/casino/holdem/render.js | 24 ++++-
.../wwwroot/js/casino/holdem/state.js | 15 ++-
tests/holdem/discardedPot.test.js | 94 +++++++++++++++++++
5 files changed, 139 insertions(+), 4 deletions(-)
create mode 100644 tests/holdem/discardedPot.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 7b6ada2..ae5cbb5 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -127,6 +127,10 @@
Chips in play
+
+ Taken off the table
+
+
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index d2a1da3..13c732a 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -387,6 +387,12 @@ document.addEventListener("DOMContentLoaded", () => {
: "";
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
+ // Written down as well as announced. The toast is a moment; the bank check
+ // is asked at cash-out, and until now it saw only that the chips were gone
+ // and reported them as missing.
+ if (unresolved > Betting.EPSILON_CHIPS) {
+ state.discarded = Money.round((Number(state.discarded) || 0) + unresolved);
+ }
HoldemState.startNextHand(state);
});
});
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 87838cf..0f03a67 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -876,8 +876,13 @@ const Render = {
note.textContent =
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
} else {
- note.textContent =
- `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
+ // If a pot was thrown away on purpose, that's where the gap went, and the
+ // dealer already answered a dialog about it. Telling them to go and fix it
+ // would be sending them after their own decision.
+ const discarded = Math.max(0, Number(this.state.discarded) || 0);
+ note.textContent = discarded > 0
+ ? `${Money.format(plan.overBy)} is owed with nobody to pay it to. ${Money.format(discarded)} was taken off the table when a hand was abandoned, which accounts for ${discarded >= plan.overBy ? "it" : "some of it"}.`
+ : `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
}
note.classList.toggle("settleUpOff", !plan.balanced);
},
@@ -896,9 +901,22 @@ const Render = {
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
+ // An abandoned pot the dealer confirmed throwing away is money that left the
+ // table on purpose. It gets its own line so the two columns still add up in
+ // front of them, rather than looking like a shortfall they have to explain.
+ const discardedRow = document.getElementById("bankDiscardedRow");
+ if (discardedRow) {
+ discardedRow.hidden = !(bank.discarded > 0);
+ if (bank.discarded > 0) {
+ document.getElementById("bankDiscarded").textContent = Money.format(bank.discarded);
+ }
+ }
+
const verdict = document.getElementById("bankReconcileVerdict");
if (bank.balanced) {
- verdict.textContent = "Balanced — the cash covers every chip on the table.";
+ verdict.textContent = bank.discarded > 0
+ ? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
+ : "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 4228ba4..72df7cd 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -51,6 +51,9 @@ const HoldemState = {
notes: "",
handNumber: 1,
handHistory: [],
+ // Chips taken off the table on purpose, so the bank check can tell them
+ // apart from chips that have gone missing.
+ discarded: 0,
smallBlind: 1,
bigBlind: 2,
defaultStack: null,
@@ -146,6 +149,7 @@ const HoldemState = {
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
+ if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
// Tables saved before live betting existed are missing its fields; fill
// them in rather than making the dealer reset a table mid-session.
@@ -590,10 +594,19 @@ const HoldemState = {
0
);
- const difference = Money.round(chipsInPlay - cashIn);
+ // Chips the dealer deliberately took off the table — an abandoned pot they
+ // were asked about and confirmed. They are gone, so they can't be counted as
+ // in play, but they aren't missing either: the box knows exactly where they
+ // went. Without this the check blamed the tool's own confirmed action on
+ // "someone has paid for chips they haven't been given", which sends a dealer
+ // hunting for a miscount that never happened.
+ const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
+
+ const difference = Money.round(chipsInPlay + discarded - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
+ discarded,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
diff --git a/tests/holdem/discardedPot.test.js b/tests/holdem/discardedPot.test.js
new file mode 100644
index 0000000..425b52e
--- /dev/null
+++ b/tests/holdem/discardedPot.test.js
@@ -0,0 +1,94 @@
+// Chips the dealer took off the table on purpose.
+//
+// Starting the next hand with money still in the middle destroys it — the tool asks
+// first and says so afterwards. What it didn't do was remember, so the bank check saw
+// only that chips were gone and reported them as missing.
+const test = require("node:test");
+const assert = require("node:assert");
+const { HoldemState, Money, makeTable } = require("./load");
+
+const table = () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
+ state.seats.slice(0, 4).forEach(player => { player.buyIns = 200; });
+ return state;
+};
+
+test("a table with nothing thrown away balances as before", () => {
+ const bank = HoldemState.bankReconciliation(table());
+
+ assert.equal(bank.balanced, true);
+ assert.equal(bank.discarded, 0);
+});
+
+test("chips that went missing are still reported", () => {
+ // The check exists for this: cash in the box with no chips to match it.
+ const state = table();
+ state.seats[0].stack = 150;
+
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.balanced, false);
+ assert.equal(bank.difference, -50);
+});
+
+test("an abandoned pot is accounted for rather than counted as missing", () => {
+ const state = table();
+ state.seats[1].stack = 199;
+ state.seats[2].stack = 198;
+ state.seats[3].stack = 198;
+ state.discarded = 5;
+
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.chipsInPlay, 795);
+ assert.equal(bank.discarded, 5);
+ assert.equal(bank.balanced, true, "the box knows exactly where that $5 went");
+});
+
+test("throwing a pot away doesn't hide a real shortfall underneath it", () => {
+ const state = table();
+ state.seats[1].stack = 199;
+ state.seats[2].stack = 198;
+ state.seats[3].stack = 148; // $50 genuinely unaccounted for as well
+ state.discarded = 5;
+
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.balanced, false);
+ assert.equal(bank.difference, -50);
+});
+
+test("a nonsense discarded value can't move the answer", () => {
+ const state = table();
+ state.discarded = -100;
+ assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
+
+ state.discarded = "not a number";
+ assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
+});
+
+test("a table saved before this existed reads as nothing thrown away", () => {
+ const saved = JSON.stringify(table());
+ const parsed = JSON.parse(saved);
+ delete parsed.discarded;
+
+ const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
+ globalThis.localStorage = {
+ getItem: key => (store.has(key) ? store.get(key) : null),
+ setItem: (key, value) => store.set(key, value),
+ removeItem: key => store.delete(key),
+ };
+
+ const loaded = HoldemState.load();
+ assert.equal(loaded.discarded, 0);
+ assert.equal(HoldemState.bankReconciliation(loaded).balanced, true);
+ delete globalThis.localStorage;
+});
+
+test("the amounts still add up the way the panel shows them", () => {
+ const state = table();
+ state.seats[1].stack = 199;
+ state.seats[2].stack = 198;
+ state.seats[3].stack = 198;
+ state.discarded = 5;
+
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(Money.round(bank.chipsInPlay + bank.discarded), bank.cashIn);
+});
From cc060b29b8d328490b3d2408f5ca04f6fe112a49 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 22:04:57 -0600
Subject: [PATCH 28/55] Keep the big blind moving one player at a time when
somebody leaves out of the blinds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Passing the button to the next player is right until a chair between the button and
the blinds empties. Then it pulls the whole blind order forward a place. Measured,
four-handed, button on Ben, blinds Cal and Dee, with Cal cashing out:
tool: button Dee, small blind Ann, big blind Ben
correct: dead button on Cal's chair, small blind Dee, big blind Ann
Ann is never asked for the big blind she owes, and Ben pays one a full orbit early.
That is a real big blind out of a real stack, and it happens every time somebody
stands up out of the blinds — which in a cash game is constantly. It is also the
kind of thing the dealer gets blamed for, because the tool is the thing everyone at
the table is reading position off.
The dead button rule holds the rotation to the seats for one hand instead of to the
players. The button follows the small blind round, which at a table with nobody
missing is the same next-player-along it always was; when that player has gone the
button rests on their empty chair for a hand, and when the big blind has gone no
small blind is posted. Either way the big blind advances exactly one player, which
is the whole point of it.
Worked out from who actually posted last hand rather than from the seating as it
stands, because by the time the question is asked the people it turns on are the
ones who have left. Heads-up is exempt — the button is the small blind there and
there is no position to leave dead — as is a table too sparse to have a rotation to
hold to, and a dealer who moves the button or names a blind by hand overrules it.
Both dead positions are said out loud rather than left to look like a fault: the
deal prompt explains them in a sentence, and the dead button goes hollow and dashed
so the felt agrees with the words.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 12 +
.../wwwroot/js/casino/holdem/actionBar.js | 23 +-
.../wwwroot/js/casino/holdem/betting.js | 24 +-
.../wwwroot/js/casino/holdem/main.js | 24 +-
.../wwwroot/js/casino/holdem/render.js | 11 +-
.../wwwroot/js/casino/holdem/state.js | 89 +++++-
tests/holdem/deadButton.test.js | 268 ++++++++++++++++++
7 files changed, 432 insertions(+), 19 deletions(-)
create mode 100644 tests/holdem/deadButton.test.js
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index cbb7273..2f4a3da 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -730,6 +730,18 @@ button,
display: none;
}
+/* The dead button. It's on an empty chair on purpose, and that reads as a mistake —
+ a gold puck on nobody looks exactly like a button that has drifted and wants
+ dragging back. So it keeps the button's gold, to stay findable as the button, but
+ goes hollow and dashed: the same shape, visibly holding a place rather than
+ marking a player. The deal prompt says why in words; this is so the felt agrees
+ with it at a glance instead of contradicting it. */
+.dealerChip.active.dealerChipDead {
+ background: rgba(245, 213, 71, 0.14);
+ border-style: dashed;
+ color: #f0d98c;
+}
+
.blindBadge {
display: none;
position: absolute;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 0e5d969..fbf55a4 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -601,12 +601,27 @@ const ActionBar = {
return wrap;
}
- line.textContent =
- `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
- `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
- `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
+ // A dead small blind is a hand with only one blind in it. Saying "nobody posts
+ // the small blind" out loud is the whole point — a dealer who reads a preview
+ // with a blind missing and isn't told why will assume the tool has lost track.
+ line.textContent = preview.deadSmallBlind
+ ? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
+ : `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
+ `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
+ `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
wrap.appendChild(line);
+ // Why a position is missing. Both of these are the dead button rule doing its
+ // job, and both look like a bug until somebody says otherwise.
+ if (preview.deadButton || preview.deadSmallBlind) {
+ const dead = document.createElement("p");
+ dead.className = "dealPromptLine dealPromptAside";
+ dead.textContent = preview.deadButton
+ ? `Dead button — seat ${preview.buttonIndex + 1} is empty. Its player left out of the blinds, so the button waits a hand rather than moving the big blind on twice.`
+ : `Dead small blind — the player due to post it left the table. The big blind still moves on one player, so nobody pays two in an orbit.`;
+ wrap.appendChild(dead);
+ }
+
// Dealing relocates a button sitting on an empty chair. That has to be said
// before it happens rather than discovered afterwards — a fresh table starts
// the button on seat 1 whether or not anyone is sitting there.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 0bf6856..bbc03c4 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -78,10 +78,11 @@ const Betting = {
: { ...dealt, dealerSeatIndex: buttonIndex };
const satOut = this.brokeSeats(state);
- const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
- if (sbIndex === null || bbIndex === null) return null;
+ const { sbIndex, bbIndex, headsUp, manual, deadSmallBlind } = HoldemState.getBlindSeats(asDealt);
+ if (bbIndex === null) return null;
return {
- sbIndex, bbIndex, headsUp, manual: !!manual,
+ sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind,
+ deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand,
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
satOut,
@@ -448,16 +449,27 @@ const Betting = {
const bb = Number(state.bigBlind) || 0;
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
- if (sbIndex === null || bbIndex === null) {
+ // A null big blind means there aren't enough players to post at all. A null
+ // small blind with a big blind behind it is the dead small blind, which is a
+ // hand that gets dealt like any other — it just has one blind in it.
+ if (bbIndex === null) {
state.currentBet = 0;
state.actingSeatIndex = null;
+ state.postedBlinds = null;
return;
}
- if (sb > 0) this.putIn(state, sbIndex, sb);
+ if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
if (bb > 0) this.putIn(state, bbIndex, bb);
- state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
+ // What the next hand's rotation is worked out from. Recorded here, where the
+ // blinds are actually posted, because by the time it's needed the seats have
+ // moved on and the answer can no longer be read off the table.
+ state.postedBlinds = { sbIndex, bbIndex };
+
+ state.currentBet = this.round(Math.max(
+ sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
+ this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
// Heads-up the button is the small blind and acts first before the flop; with
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 13c732a..95dd018 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -121,6 +121,11 @@ document.addEventListener("DOMContentLoaded", () => {
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
: `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
+ // Putting the button somewhere by hand is the dealer overruling the
+ // rotation, dead positions included. Leaving them set would hold a
+ // small blind dead at a table whose button has moved out from under it.
+ state.deadButton = false;
+ state.deadSmallBlind = false;
if (willRepost) Betting.repostBlinds(state);
});
},
@@ -376,12 +381,19 @@ document.addEventListener("DOMContentLoaded", () => {
if (!ok) return;
}
- // startNextHand moves the button on by one; say where it landed so the
- // dealer can confirm the rotation without hunting for the badge.
- const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
- const where = state.dedicatedDealer
- ? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
- : `button on seat ${nextDealer + 1}`;
+ // Say where the button landed so the dealer can confirm the rotation without
+ // hunting for the badge. Asked of the rotation itself rather than guessed at as
+ // "the next chair along" — which was only ever right at a table with nobody
+ // missing, and is the exact thing the dead button makes untrue.
+ const rotation = HoldemState.nextButtonRotation(state);
+ const nextDealer = rotation.buttonIndex !== null
+ ? rotation.buttonIndex
+ : HoldemState.buttonSeatForDeal({ ...state, dealerSeatIndex: HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1) });
+ const where = rotation.deadButton
+ ? `dead button on seat ${nextDealer + 1}`
+ : state.dedicatedDealer
+ ? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
+ : `button on seat ${nextDealer + 1}`;
const discarded = unresolved > Betting.EPSILON_CHIPS
? ` ${Money.format(unresolved)} taken off the table.`
: "";
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 0f03a67..b413975 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -173,6 +173,12 @@ const Render = {
layer.innerHTML = "";
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
+ // A button on an empty chair normally means the table has drifted and wants
+ // fixing. Once it's the dead button it means the opposite — it's exactly where
+ // it belongs — so it gets marked rather than looking like the thing to correct.
+ const deadButtonHere = this.state.deadButton &&
+ HoldemState.deadPositionsApply(this.state) &&
+ !this.state.seats[dealerIndex].inHand;
this.state.seats.forEach((player, index) => {
const node = template.content.firstElementChild.cloneNode(true);
@@ -201,8 +207,11 @@ const Render = {
// of nine identical buttons and couldn't tell which seat held the real one.
// The label now says whose it is, or what pressing it would do.
const who = (player.name || "").trim() || `seat ${index + 1}`;
+ dealerChip.classList.toggle("dealerChipDead", hasButton && deadButtonHere);
const chipLabel = hasButton
- ? `${who} has the dealer button`
+ ? (deadButtonHere
+ ? `Dead button on empty seat ${index + 1} — its player left out of the blinds`
+ : `${who} has the dealer button`)
: `Give the dealer button to ${who}`;
dealerChip.setAttribute("aria-label", chipLabel);
dealerChip.title = chipLabel;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 72df7cd..b63041c 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -81,6 +81,16 @@ const HoldemState = {
// button order is the wrong answer often enough that it has to be
// overridable. Cleared at the start of every hand.
blindOverride: null,
+ // Who actually posted, the last time cards went out — { sbIndex, bbIndex }.
+ // The next hand's rotation is worked out from this rather than from the
+ // seating as it stands, because by then the people it needs to know about
+ // are exactly the ones who have got up and gone.
+ postedBlinds: null,
+ // Set when somebody leaves out of the blinds and the rotation has to hold
+ // to the seats to keep the big blind moving one player at a time. The
+ // button then sits on the chair they vacated, and no small blind is posted.
+ deadButton: false,
+ deadSmallBlind: false,
};
},
@@ -155,11 +165,17 @@ const HoldemState = {
// them in rather than making the dealer reset a table mid-session.
const defaults = this.createDefault();
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
- "actingSeatIndex", "blindOverride"]
+ "actingSeatIndex", "blindOverride", "postedBlinds"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
+ // A table saved before the dead button existed has neither flag. False is
+ // the right guess for both: it means the rotation carries on the ordinary
+ // way, which is what that table was already doing.
+ parsed.deadButton = parsed.deadButton === true;
+ parsed.deadSmallBlind = parsed.deadSmallBlind === true;
+
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
// progress. Anything already committed, or anyone already on the clock,
@@ -327,6 +343,10 @@ const HoldemState = {
// sits on nobody most hands — and that isn't only untidy: position and blinds are
// supposed to come round to each player at the same rate, and they don't if the
// button is rotating past chairs nobody is sitting in.
+ //
+ // The fallback, now. nextButtonRotation is what a hand that was actually dealt
+ // rotates by; this is for the cases it has nothing to work from — a fresh table,
+ // a hand never dealt, a dealer pressing Next Hand twice to pass the button on.
advanceButton(state) {
for (let step = 1; step <= state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
@@ -365,6 +385,10 @@ const HoldemState = {
buttonSeatForDeal(state) {
const seated = state.seats[state.dealerSeatIndex];
if (seated && seated.inHand) return state.dealerSeatIndex;
+ // A button left on an empty chair on purpose is the dead button, and moving it
+ // to a player is precisely what it's there to stop. Only a button that has been
+ // stranded by accident gets rescued.
+ if (state.deadButton && this.deadPositionsApply(state)) return state.dealerSeatIndex;
for (let step = 0; step < state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) return index;
@@ -387,6 +411,13 @@ const HoldemState = {
return o;
},
+ // A dead button and a dead small blind are three-handed-and-up ideas. Down to two
+ // players the button *is* the small blind and the blinds simply alternate, so
+ // whatever the last rotation left behind stops applying.
+ deadPositionsApply(state) {
+ return state.seats.filter(p => p.inHand).length >= 3;
+ },
+
// Who posts what. Heads-up is its own rule: with two players the button posts the
// small blind, which is the opposite of the usual order.
getBlindSeats(state) {
@@ -418,11 +449,65 @@ const HoldemState = {
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
}
+ // The small blind is dead: whoever would have moved into it has left the table,
+ // and the seat stays empty for a hand rather than dragging the big blind
+ // forward an extra place. One player gets a free hand out of position for it;
+ // that's the price of nobody being charged two big blinds in an orbit.
+ if (state.deadSmallBlind && this.deadPositionsApply(state)) {
+ return { dealerIndex, sbIndex: null, bbIndex: live[0], headsUp: false, deadSmallBlind: true };
+ }
+
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
},
+ // Where the button goes for the next hand, and whether anything is left dead.
+ //
+ // Ordinarily it follows the small blind round — the next player along, which is
+ // what it has always done. The case that needs care is somebody getting up out of
+ // the blinds. Passing the button to the next *player* then pulls the whole blind
+ // order forward a place: the player after the empty chair is never asked for a big
+ // blind at all, and the one after them is asked an orbit early. Holding the
+ // rotation to the seats instead is the dead button, and getting it wrong costs a
+ // real player a real big blind.
+ //
+ // Worked out from who actually posted last hand, not from the seating as it stands,
+ // because by now the people it needs to know about are the ones who have gone.
+ nextButtonRotation(state) {
+ const posted = state.postedBlinds;
+ const inRange = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i];
+ const here = i => inRange(i) && state.seats[i].inHand;
+
+ // Nothing to hold to: a fresh table, a hand that was never dealt, or one where
+ // the blinds were named by hand. And heads-up has its own rule — with two
+ // players the button is the small blind, so there is no position to leave dead.
+ if (!posted || !inRange(posted.sbIndex) || !inRange(posted.bbIndex) || !this.deadPositionsApply(state)) {
+ return { buttonIndex: null, deadButton: false, deadSmallBlind: false };
+ }
+
+ // Both still sitting there is the ordinary case, and this comes out as the next
+ // player along — the same answer as passing the button, arrived at from the
+ // blinds so that the unusual case doesn't need a separate path.
+ return {
+ buttonIndex: posted.sbIndex,
+ deadButton: !here(posted.sbIndex),
+ deadSmallBlind: !here(posted.bbIndex),
+ };
+ },
+
startNextHand(state) {
- this.advanceButton(state);
+ const rotation = this.nextButtonRotation(state);
+ if (rotation.buttonIndex === null) {
+ this.advanceButton(state);
+ state.deadButton = false;
+ state.deadSmallBlind = false;
+ } else {
+ state.dealerSeatIndex = rotation.buttonIndex;
+ state.deadButton = rotation.deadButton;
+ state.deadSmallBlind = rotation.deadSmallBlind;
+ }
+ // Spent. Pressing Next Hand again without dealing is a dealer passing the
+ // button along, not a second rotation off the same hand.
+ state.postedBlinds = null;
// Cards aren't out and nothing is posted until the dealer says so.
state.bettingOpen = false;
state.actingSeatIndex = null;
diff --git a/tests/holdem/deadButton.test.js b/tests/holdem/deadButton.test.js
new file mode 100644
index 0000000..4db4157
--- /dev/null
+++ b/tests/holdem/deadButton.test.js
@@ -0,0 +1,268 @@
+// The button when somebody gets up out of the blinds.
+//
+// Passing the button to the next *player* is right until a chair between the button
+// and the blinds empties. Then it pulls the whole blind order forward a place: the
+// player after the empty chair is never asked for a big blind, and the one after them
+// is asked an orbit early. The dead button rule holds the rotation to the seats for
+// one hand so the big blind still advances exactly one player.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { HoldemState, Betting, makeTable } = require("./load");
+
+// Four players in six chairs: Ann 0, Ben 1, Cal 2, Dee 3.
+const NAMES = ["Ann", "Ben", "Cal", "Dee"];
+const table = (dealerSeatIndex = 0) => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
+ NAMES.forEach((name, i) => { state.seats[i].name = name; });
+ return state;
+};
+
+const nameOf = (state, index) =>
+ index === null || index === undefined ? null : (state.seats[index].name || `empty seat ${index + 1}`);
+
+const blinds = state => {
+ const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
+ return { sb: nameOf(state, sbIndex), bb: nameOf(state, bbIndex) };
+};
+
+const leave = (state, index) => {
+ state.seats[index].name = "";
+ state.seats[index].inHand = false;
+ state.seats[index].stack = 0;
+ state.seats[index].buyIns = 0;
+};
+
+describe("somebody gets up out of the blinds", () => {
+ test("the small blind leaves: dead button, and the big blind still moves one player", () => {
+ const state = table(1); // button Ben, small Cal, big Dee
+ Betting.beginHand(state);
+ assert.deepEqual(blinds(state), { sb: "Cal", bb: "Dee" });
+
+ leave(state, 2); // Cal takes their chips and goes
+ HoldemState.startNextHand(state);
+ Betting.beginHand(state);
+
+ assert.equal(state.dealerSeatIndex, 2, "the button holds Cal's empty chair");
+ assert.equal(state.seats[2].inHand, false, "which is the point — it's on nobody");
+ assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ann" },
+ "Dee moves from big to small and Ann posts the big blind, one player along");
+ });
+
+ test("and Ann is not skipped, which is the money in it", () => {
+ // The old rotation passed the button to the next player, which made the blinds
+ // Ann/Ben — Ann never posting a big blind at all, and Ben posting one an orbit
+ // early. That is a real big blind out of a real player's stack.
+ const state = table(1);
+ Betting.beginHand(state);
+ leave(state, 2);
+ HoldemState.startNextHand(state);
+
+ assert.equal(blinds(state).bb, "Ann", "Ann owes the big blind and still owes it");
+ });
+
+ test("the big blind leaves: no small blind is posted this hand", () => {
+ const state = table(1); // button Ben, small Cal, big Dee
+ Betting.beginHand(state);
+
+ leave(state, 3); // Dee goes
+ HoldemState.startNextHand(state);
+ Betting.beginHand(state);
+
+ const { sbIndex, bbIndex, deadSmallBlind } = HoldemState.getBlindSeats(state);
+ assert.equal(state.dealerSeatIndex, 2, "the button reaches Cal as it was always going to");
+ assert.equal(sbIndex, null, "the small blind is dead");
+ assert.equal(deadSmallBlind, true, "and says so, rather than looking like a table with no blinds");
+ assert.equal(nameOf(state, bbIndex), "Ann", "the big blind still moves on one player");
+ });
+
+ test("a dead small blind still deals a hand, with one blind in the middle", () => {
+ const state = table(1);
+ Betting.beginHand(state);
+ leave(state, 3);
+ HoldemState.startNextHand(state);
+ Betting.beginHand(state);
+
+ assert.equal(state.bettingOpen, true, "the hand goes ahead");
+ const posted = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
+ assert.equal(posted, state.bigBlind, "just the big blind, and nothing conjured to stand in for the small");
+ assert.equal(state.currentBet, state.bigBlind);
+ assert.ok(state.actingSeatIndex !== null, "and somebody is on the clock");
+ });
+
+ test("somebody leaving from anywhere else changes nothing", () => {
+ const state = table(1); // button Ben, small Cal, big Dee
+ Betting.beginHand(state);
+
+ leave(state, 0); // Ann, who was under the gun
+ HoldemState.startNextHand(state);
+
+ assert.equal(state.dealerSeatIndex, 2, "the button passes to Cal exactly as before");
+ assert.equal(state.deadButton, false);
+ assert.equal(state.deadSmallBlind, false);
+ assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ben" });
+ });
+});
+
+describe("the ordinary rotation is untouched", () => {
+ test("a full table passes the button one player at a time", () => {
+ const state = table(0);
+ const seen = [];
+ for (let hand = 0; hand < 8; hand++) {
+ Betting.beginHand(state);
+ seen.push(blinds(state).bb);
+ HoldemState.startNextHand(state);
+ }
+ assert.deepEqual(seen, ["Cal", "Dee", "Ann", "Ben", "Cal", "Dee", "Ann", "Ben"],
+ "two clean orbits, everybody paying one big blind per orbit");
+ assert.equal(state.deadButton, false);
+ assert.equal(state.deadSmallBlind, false);
+ });
+
+ test("players spread thinly around a big table don't create dead positions", () => {
+ // Three players in nine chairs. The chairs between them were never in the
+ // rotation, so holding to seats there would post blinds to nobody hand after
+ // hand — which is why the button follows players, not chairs, by default.
+ const state = makeTable({ seatCount: 9, seated: [0, 4, 7], stack: 200, dealerSeatIndex: 0 });
+ ["Ann", "", "", "", "Ben", "", "", "Cal"].forEach((n, i) => { state.seats[i].name = n; });
+
+ const seen = [];
+ for (let hand = 0; hand < 6; hand++) {
+ Betting.beginHand(state);
+ const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
+ assert.ok(state.seats[sbIndex].inHand, "a real player posts the small blind");
+ assert.ok(state.seats[bbIndex].inHand, "and a real player the big");
+ seen.push(nameOf(state, bbIndex));
+ HoldemState.startNextHand(state);
+ }
+ assert.deepEqual(seen, ["Cal", "Ann", "Ben", "Cal", "Ann", "Ben"], "one big blind each per orbit");
+ });
+
+ test("pressing next hand twice without dealing just passes the button along", () => {
+ const state = table(0);
+ Betting.beginHand(state);
+
+ HoldemState.startNextHand(state);
+ assert.equal(state.dealerSeatIndex, 1);
+ HoldemState.startNextHand(state);
+ assert.equal(state.dealerSeatIndex, 2, "and doesn't rotate off the same hand twice");
+ });
+});
+
+describe("the rule knows where it stops", () => {
+ test("down to two players, heads-up takes over and nothing is left dead", () => {
+ const state = table(1);
+ Betting.beginHand(state);
+ leave(state, 2);
+ leave(state, 3); // both blinds walk at once
+
+ HoldemState.startNextHand(state);
+ Betting.beginHand(state);
+
+ const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
+ assert.equal(headsUp, true);
+ assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
+ assert.ok(state.seats[state.dealerSeatIndex].inHand, "and the button is on a player again");
+ });
+
+ test("a button stranded by accident is still rescued", () => {
+ // Not the dead button: nobody left out of the blinds, the chair the button is
+ // on just emptied. That one does want putting back on a player.
+ const state = table(3);
+ state.seats[3].inHand = false;
+ HoldemState.ensureButtonOnPlayer(state);
+ assert.ok(state.seats[state.dealerSeatIndex].inHand);
+ });
+
+ test("moving the button by hand clears the dead positions", () => {
+ const state = table(1);
+ Betting.beginHand(state);
+ leave(state, 2);
+ HoldemState.startNextHand(state);
+ assert.equal(state.deadButton, true);
+
+ // What the seat menu's "Give the button" does.
+ state.dealerSeatIndex = 0;
+ state.deadButton = false;
+ state.deadSmallBlind = false;
+ Betting.beginHand(state);
+
+ const { sbIndex } = HoldemState.getBlindSeats(state);
+ assert.ok(state.seats[sbIndex].inHand, "a real small blind again, where the dealer put it");
+ });
+
+ test("naming a blind by hand overrules a dead one", () => {
+ const state = table(1);
+ Betting.beginHand(state);
+ leave(state, 3);
+ HoldemState.startNextHand(state);
+ assert.equal(state.deadSmallBlind, true);
+
+ state.blindOverride = { sbIndex: 1, bbIndex: 0 };
+ const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
+ assert.equal(manual, true);
+ assert.equal(sbIndex, 1, "the dealer's word beats the rule");
+ assert.equal(bbIndex, 0);
+ });
+
+ test("a table saved before any of this reads as nothing dead", () => {
+ const state = table(0);
+ const parsed = JSON.parse(JSON.stringify(state));
+ delete parsed.deadButton;
+ delete parsed.deadSmallBlind;
+ delete parsed.postedBlinds;
+
+ const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
+ globalThis.localStorage = {
+ getItem: key => (store.has(key) ? store.get(key) : null),
+ setItem: (key, value) => store.set(key, value),
+ removeItem: key => store.delete(key),
+ };
+
+ const loaded = HoldemState.load();
+ assert.equal(loaded.deadButton, false);
+ assert.equal(loaded.deadSmallBlind, false);
+ assert.equal(loaded.postedBlinds, null);
+
+ HoldemState.startNextHand(loaded);
+ assert.ok(loaded.seats[loaded.dealerSeatIndex].inHand, "and rotates the ordinary way");
+ delete globalThis.localStorage;
+ });
+});
+
+describe("chips are not invented or lost by any of it", () => {
+ test("posting one blind instead of two moves chips and doesn't make them", () => {
+ // Measured across the deal, which is the step that touches stacks. A hand with
+ // a dead small blind has one blind in the middle instead of two — the danger is
+ // a null small blind being treated as a zero-cost posting somewhere, or the big
+ // blind quietly covering for it.
+ const state = table(1);
+ const chips = s => s.seats.reduce(
+ (sum, p) => sum + Betting.stackOf(s, p) + Betting.handTotal(p), 0);
+
+ Betting.beginHand(state);
+ leave(state, 3); // the big blind goes, small blind dies
+
+ for (let hand = 0; hand < 4; hand++) {
+ HoldemState.startNextHand(state);
+ const before = chips(state);
+ Betting.beginHand(state);
+ assert.equal(chips(state), before, `the deal drifted the count on hand ${hand + 1}`);
+ }
+ });
+
+ test("a dead small blind takes exactly one blind out of one stack", () => {
+ const state = table(1);
+ Betting.beginHand(state);
+ leave(state, 3);
+ HoldemState.startNextHand(state);
+
+ const stacks = () => state.seats.map(p => Betting.stackOf(state, p));
+ const before = stacks();
+ Betting.beginHand(state);
+ const after = stacks();
+
+ const paid = before.map((v, i) => v - after[i]).filter(d => d !== 0);
+ assert.deepEqual(paid, [state.bigBlind], "one player, one big blind, nobody else touched");
+ });
+});
From c76ebf4d1852f685fbaee4acb3c2c28f0c67b52c Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 22:27:22 -0600
Subject: [PATCH 29/55] Say when somebody is being dealt in for free, and give
the dealer the post
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sitting down between the button and the big blind means the blinds have gone past
you. The tool dealt those players straight in, silently. Measured: Eve takes a seat,
and the next hand she is dealt cards in late position having put nothing in — a free
hand, out of everyone else's night a couple of dollars at a time, with nothing
anywhere on screen to tell the dealer it had happened.
At a table there are two answers and the player picks one: post the big blind and
come in now, or wait for the blind to reach them. The tool did neither and named
neither.
The deal prompt now names anyone about to be dealt in who has not paid for it, and
carries the post as one tap. Nothing is forced — a home game that doesn't care can
deal as before, and the sentence is still worth having on its own, because the
dealer at least knows. The post goes in as a big blind out of that player's own
stack, is live like any blind, and is spent at the end of the hand rather than
becoming a standing charge.
Somebody back from sitting out is owed one the same as a newcomer. The big blind of
the hand isn't — they're already paying — and neither is anyone at a table that has
not dealt a hand yet, where the whole table is new and nobody has been skipped.
Deliberately one line for however many have walked up, not one each: the bar is
docked over the felt and the felt has already shrunk as far as it goes on a phone,
so every line costs seat area. A line apiece put the bar over two fifths of the
bottom seat at 390x844.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 32 +++
.../wwwroot/js/casino/holdem/actionBar.js | 38 +++
.../wwwroot/js/casino/holdem/betting.js | 24 ++
.../wwwroot/js/casino/holdem/state.js | 40 +++
tests/holdem/postingIn.test.js | 238 ++++++++++++++++++
5 files changed, 372 insertions(+)
create mode 100644 tests/holdem/postingIn.test.js
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 2f4a3da..a41d98e 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -3406,6 +3406,38 @@ textarea:focus-visible {
color: #9a9a9a;
}
+/* An aside that has something to press. It wraps rather than squeezing the button:
+ on a phone the sentence takes the width it needs and the button drops under it,
+ still at full tap size, instead of being crushed to a sliver at the end of a line. */
+.dealPromptLine.dealPromptDebt {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem 0.55rem;
+}
+
+.dealPromptDebtBtn {
+ /* Sized down from a full action button — it's an option beside a sentence, not
+ the thing the dealer came here to press. "Post blinds & deal" stays the button
+ that looks like the button. */
+ flex: 0 0 auto;
+ min-height: 32px;
+ padding: 0.24rem 0.7rem;
+ font-size: 0.72rem;
+ background: rgba(245, 213, 71, 0.1);
+ border: 1px solid rgba(245, 213, 71, 0.45);
+ color: #f0d98c;
+}
+
+@media (max-width: 700px), (pointer: coarse) {
+ /* A fingertip needs 44px, and this one moves money. */
+ .dealPromptDebtBtn {
+ min-height: 44px;
+ padding: 0.4rem 0.9rem;
+ }
+}
+
/* ---------- Touch targets on a wide touch screen ----------
Every touch-target rule in this file sits behind `max-width: 700px`, and width was
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index fbf55a4..afba098 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -643,6 +643,44 @@ const ActionBar = {
wrap.appendChild(out);
}
+ // Somebody who has just sat down between the button and the big blind has had
+ // the blinds go past them, and dealing them in there is a free hand in late
+ // position. At a table the answer is that they post the big blind to come in
+ // now, or wait for it to reach them. The tool used to do neither and say
+ // nothing: it dealt them in for free, and the dealer had no way to know it had
+ // happened. Both answers are offered here; neither is forced, because a home
+ // game that doesn't care shouldn't have to argue with its own scoreboard.
+ // One line for however many have walked up, not one each. The bar is docked
+ // over the felt and the felt has already shrunk as far as it will go on a
+ // phone, so every line here is taken out of the ring of seats: measured at
+ // 390x844 with two newcomers, a line apiece put the bar over two fifths of the
+ // bottom seat. They post together or they wait together, which is what happens
+ // at a table anyway when two people sit down at once.
+ if (preview.debtors.length > 0) {
+ const ids = preview.debtors.map(i => this.state.seats[i].id);
+ const posting = ids.every(id => preview.posting.includes(id));
+ const who = listNames(preview.debtors);
+ const each = preview.debtors.length > 1 ? " each" : "";
+
+ const line = document.createElement("p");
+ line.className = "dealPromptLine dealPromptAside dealPromptDebt";
+
+ const text = document.createElement("span");
+ text.textContent = posting
+ ? `${who} posting ${Money.format(preview.bigBlind)}${each} to come in.`
+ : `${who} sat down after the blinds — ${preview.debtors.length > 1 ? "free hands" : "a free hand"} unless they post.`;
+ line.appendChild(text);
+
+ line.appendChild(this.button(
+ posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
+ "dealPromptDebtBtn",
+ () => this.commit(
+ posting ? `${who} waiting for the blind` : `${who} posting to come in`,
+ () => { this.state.postingIn = posting ? [] : ids; })
+ ));
+ wrap.appendChild(line);
+ }
+
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index bbc03c4..3f78c8e 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -83,6 +83,11 @@ const Betting = {
return {
sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind,
deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand,
+ // Asked of the table as it will be dealt, for the same reason the blinds
+ // are: someone sitting out for having no chips isn't owed a blind, and the
+ // big blind of the hand about to go out isn't either.
+ debtors: HoldemState.blindDebtors(asDealt, bbIndex),
+ posting: (state.postingIn || []).slice(),
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
satOut,
@@ -462,11 +467,30 @@ const Betting = {
if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
if (bb > 0) this.putIn(state, bbIndex, bb);
+ // Anyone the dealer said would post to come in past the blinds. It's a big
+ // blind like any other — it buys them a hand they'd otherwise have to wait for
+ // — so it goes in the middle the same way, before the count below reads it.
+ const posting = Array.isArray(state.postingIn) ? state.postingIn : [];
+ if (bb > 0) {
+ state.seats.forEach((player, index) => {
+ if (index === sbIndex || index === bbIndex) return;
+ if (player.inHand && posting.includes(player.id)) this.putIn(state, index, bb);
+ });
+ }
+ // Deliberately not cleared here. repostBlinds takes every posted chip back and
+ // deals again through this same function, so a post that cleared itself on the
+ // way in would be refunded and never retaken — the dealer moves the button and
+ // somebody's post quietly vanishes. startNextHand is what ends it.
+
// What the next hand's rotation is worked out from. Recorded here, where the
// blinds are actually posted, because by the time it's needed the seats have
// moved on and the answer can no longer be read off the table.
state.postedBlinds = { sbIndex, bbIndex };
+ // Who got cards. Read next hand to tell a player who has been here all along
+ // from one who has just walked up and had the blinds go past them.
+ state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
+
state.currentBet = this.round(Math.max(
sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
this.committed(state.seats[bbIndex])));
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index b63041c..888034f 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -86,6 +86,14 @@ const HoldemState = {
// seating as it stands, because by then the people it needs to know about
// are exactly the ones who have got up and gone.
postedBlinds: null,
+ // Player ids that were dealt in the last hand actually dealt. What makes a
+ // seat recognisable as having *just* joined — new to the table, or back
+ // from sitting out — which is the whole question behind owing a blind.
+ dealtLastHand: [],
+ // Ids the dealer has said will buy in past the blinds this hand, by posting
+ // the big blind rather than waiting for it. Cleared every hand: it's a
+ // decision about one deal, not a standing arrangement.
+ postingIn: [],
// Set when somebody leaves out of the blinds and the rotation has to hold
// to the seats to keep the big blind moving one player at a time. The
// button then sits on the chair they vacated, and no small blind is posted.
@@ -175,6 +183,12 @@ const HoldemState = {
// way, which is what that table was already doing.
parsed.deadButton = parsed.deadButton === true;
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
+ // An old save has no record of who was dealt last hand. Empty is the
+ // honest answer — it reads as "this table hasn't played yet", so the first
+ // hand after reopening treats nobody as having just walked up, rather than
+ // asking the whole table to post.
+ if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
+ if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
@@ -460,6 +474,29 @@ const HoldemState = {
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
},
+ // Seats about to be dealt a hand they haven't paid for.
+ //
+ // Somebody who walks up and sits down between the button and the big blind has had
+ // the blinds go past them. Dealing them in there is a free hand in late position —
+ // which is exactly what posting is for, and it comes out of everybody else's night
+ // a couple of dollars at a time. At a real table they either post the big blind to
+ // come straight in, or they wait for it to reach them.
+ //
+ // The seat that *is* the big blind this hand isn't owed anything: they're paying.
+ // Neither is anyone at a table that hasn't dealt a hand yet, where everybody is new
+ // and nobody has been skipped.
+ blindDebtors(state, bbIndex) {
+ const played = Array.isArray(state.dealtLastHand) && state.dealtLastHand.length > 0;
+ if (!played) return [];
+ return state.seats
+ .map((player, index) => ({ player, index }))
+ .filter(({ player, index }) =>
+ player.inHand &&
+ index !== bbIndex &&
+ !state.dealtLastHand.includes(player.id))
+ .map(({ index }) => index);
+ },
+
// Where the button goes for the next hand, and whether anything is left dead.
//
// Ordinarily it follows the small blind round — the next player along, which is
@@ -518,6 +555,9 @@ const HoldemState = {
// the next one would freeze the blinds on two players for the rest of the
// night, which is the opposite of what naming them once is for.
state.blindOverride = null;
+ // Posting to come in is a decision about one deal. Carrying it forward would
+ // charge somebody an extra big blind every hand for the rest of the night.
+ state.postingIn = [];
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
diff --git a/tests/holdem/postingIn.test.js b/tests/holdem/postingIn.test.js
new file mode 100644
index 0000000..31c1016
--- /dev/null
+++ b/tests/holdem/postingIn.test.js
@@ -0,0 +1,238 @@
+// Somebody who walks up and sits down after the blinds have gone past them.
+//
+// Dealing them straight in is a free hand in late position — which is what posting
+// exists to stop, and it comes out of everybody else's night a couple of dollars at a
+// time. The tool used to do it silently. It now says so, and takes the post if the
+// dealer says to.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { HoldemState, Betting, makeTable } = require("./load");
+
+const NAMES = ["Ann", "Ben", "Cal", "Dee"];
+const table = (dealerSeatIndex = 0) => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
+ NAMES.forEach((name, i) => { state.seats[i].name = name; });
+ return state;
+};
+
+// Eve walks up and takes seat 5 while the table is running.
+const seatEve = state => {
+ state.seats[4].name = "Eve";
+ state.seats[4].inHand = true;
+ state.seats[4].stack = 200;
+ state.seats[4].buyIns = 200;
+ return 4;
+};
+
+const debtors = state => {
+ const { bbIndex } = HoldemState.getBlindSeats(state);
+ return HoldemState.blindDebtors(state, bbIndex).map(i => state.seats[i].name);
+};
+
+describe("who is owed a blind", () => {
+ test("nobody, at a table that hasn't dealt a hand yet", () => {
+ // Everyone is new on the first hand. Asking the whole table to post would be
+ // the tool mistaking the start of the night for five people walking up late.
+ const state = table();
+ assert.deepEqual(debtors(state), []);
+ });
+
+ test("nobody, on an ordinary hand where the same people are still sitting there", () => {
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ assert.deepEqual(debtors(state), []);
+ });
+
+ test("the player who has just sat down", () => {
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ seatEve(state);
+
+ assert.deepEqual(debtors(state), ["Eve"]);
+ });
+
+ test("not once they've been dealt a hand", () => {
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ seatEve(state);
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+
+ assert.deepEqual(debtors(state), [], "she's one of the regulars now");
+ });
+
+ test("not somebody who is the big blind this hand — they're paying already", () => {
+ const state = table(0); // blinds Ben and Cal
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state); // blinds move to Cal and Dee
+
+ // Dee sits out for a hand and comes straight back into her own big blind.
+ state.seats[3].inHand = false;
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ state.seats[3].inHand = true;
+
+ const { bbIndex } = HoldemState.getBlindSeats(state);
+ assert.ok(!HoldemState.blindDebtors(state, bbIndex).includes(bbIndex),
+ "the big blind is never asked to post on top of their blind");
+ });
+
+ test("somebody back from sitting out is owed one just like a newcomer", () => {
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+
+ state.seats[3].inHand = false; // Dee stands up for a hand
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ state.seats[3].inHand = true; // and sits back down
+
+ assert.ok(debtors(state).includes("Dee"), "the blinds went past her while she was up");
+ });
+
+ test("a seat sat out for having no chips isn't owed anything", () => {
+ // They aren't being dealt in, so there's no free hand to charge for.
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ const eve = seatEve(state);
+ state.seats[eve].stack = 0;
+
+ const preview = Betting.blindPreview(state);
+ assert.ok(!preview.debtors.includes(eve), "she's sitting out until she buys chips");
+ });
+});
+
+describe("posting to come in", () => {
+ test("the post goes in the middle and buys a hand", () => {
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ const eve = seatEve(state);
+
+ state.postingIn = [state.seats[eve].id];
+ const before = Betting.stackOf(state, state.seats[eve]);
+ Betting.beginHand(state);
+
+ assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "she's posted a big blind");
+ assert.equal(Betting.stackOf(state, state.seats[eve]), before - state.bigBlind,
+ "out of her own stack, not conjured");
+ assert.equal(state.currentBet, state.bigBlind, "and it doesn't raise the hand");
+ });
+
+ test("not posting leaves her stack alone", () => {
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ const eve = seatEve(state);
+
+ const before = Betting.stackOf(state, state.seats[eve]);
+ Betting.beginHand(state);
+
+ assert.equal(Betting.committed(state.seats[eve]), 0);
+ assert.equal(Betting.stackOf(state, state.seats[eve]), before);
+ });
+
+ test("the pot is what the blinds and the post add up to", () => {
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ const eve = seatEve(state);
+
+ state.postingIn = [state.seats[eve].id];
+ Betting.beginHand(state);
+
+ const middle = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
+ assert.equal(middle, state.smallBlind + state.bigBlind + state.bigBlind);
+ });
+
+ test("it's spent after the hand, not a standing charge", () => {
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ const eve = seatEve(state);
+
+ state.postingIn = [state.seats[eve].id];
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+
+ assert.deepEqual(state.postingIn, [], "or she'd pay an extra blind every hand all night");
+ Betting.beginHand(state);
+ assert.ok(Betting.committed(state.seats[eve]) === 0 ||
+ state.seats[eve].id === state.seats[HoldemState.getBlindSeats(state).bbIndex]?.id,
+ "she pays nothing extra on the next hand");
+ });
+
+ test("moving the button after a post doesn't lose it", () => {
+ // repostBlinds takes every posted chip back and deals again. A post that
+ // cleared itself on the way in would be refunded and never retaken.
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ const eve = seatEve(state);
+
+ state.postingIn = [state.seats[eve].id];
+ Betting.beginHand(state);
+ const chips = () => state.seats.reduce(
+ (sum, p) => sum + Betting.stackOf(state, p) + Betting.handTotal(p), 0);
+ const before = chips();
+
+ state.dealerSeatIndex = 2;
+ Betting.repostBlinds(state);
+
+ assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "still posted");
+ assert.equal(chips(), before, "and no chips made or lost in the shuffle");
+ });
+
+ test("a post from somebody who isn't owed one is not taken", () => {
+ // Only seats actually being dealt in can post, and only for their own chips.
+ const state = table();
+ state.postingIn = [state.seats[3].id];
+ state.seats[3].inHand = false;
+ Betting.beginHand(state);
+
+ assert.equal(Betting.committed(state.seats[3]), 0, "she isn't even in the hand");
+ });
+});
+
+describe("what the dealer is shown", () => {
+ test("the prompt has something to say about it, and something to press", () => {
+ const state = table();
+ Betting.beginHand(state);
+ HoldemState.startNextHand(state);
+ const eve = seatEve(state);
+
+ const preview = Betting.blindPreview(state);
+ assert.deepEqual(preview.debtors, [eve]);
+ assert.deepEqual(preview.posting, [], "not posting until the dealer says so");
+
+ state.postingIn = [state.seats[eve].id];
+ assert.deepEqual(Betting.blindPreview(state).posting, [state.seats[eve].id]);
+ });
+
+ test("an old save reads as a table nobody has just walked up to", () => {
+ const state = table();
+ Betting.beginHand(state);
+ const parsed = JSON.parse(JSON.stringify(state));
+ delete parsed.dealtLastHand;
+ delete parsed.postingIn;
+
+ const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
+ globalThis.localStorage = {
+ getItem: key => (store.has(key) ? store.get(key) : null),
+ setItem: (key, value) => store.set(key, value),
+ removeItem: key => store.delete(key),
+ };
+
+ const loaded = HoldemState.load();
+ assert.deepEqual(loaded.dealtLastHand, []);
+ assert.deepEqual(loaded.postingIn, []);
+ assert.deepEqual(debtors(loaded), [],
+ "reopening the tab doesn't ask the whole table to post");
+ delete globalThis.localStorage;
+ });
+});
From 01f5139740d745a2de8fa6925926faa4fad9850f Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 22:39:55 -0600
Subject: [PATCH 30/55] Let the dealer find one hand in the log instead of
scrolling the night
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hand log exists for one moment: somebody at the rail says they won a pot an hour
ago and wants it looked up. A night runs to sixty hands or more and the panel showed
five of them at a time, in a box fixed at 260px whether the dealer was on a phone or
a 27" screen with room for twenty. There was no way to ask for a hand — only to
scroll and read every row.
A filter box over the list now takes a name, an amount or a hand number. Matching is
loose on purpose: "fay" whatever case it's typed in, "190" for a $190 pot or a $190
payout, "$1,90" for the same typed badly, "#12" when the dealer knows the hand and
wants that exact one. Beside it, how much of the night is hidden — "8 of 60" — since
a filter matching one hand otherwise looks like a table that only played one.
The list also takes the room it has: clamp(200px, 42vh, 560px) rather than a flat
260, which is seven or eight rows instead of five on a desktop and no worse on a
phone.
Export stays the whole night, always. A CSV quietly missing the hands scrolled past
would be a record with holes in it and nothing on the file to say so — if a filter is
up when it's exported, the toast says how many went in.
Matching deliberately covers only what the row displays. Searching the full ledger as
well was the first attempt, so the hand that broke somebody could be found under the
loser's name — but the ledger holds everyone who put a chip in, and measured on a
seven-handed night where nobody folded, "Fay" returned 60 of 60. A filter that
searches text the panel doesn't show looks broken even when it's right.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 6 +
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 43 +++++-
.../wwwroot/js/casino/holdem/main.js | 18 +++
.../wwwroot/js/casino/holdem/render.js | 62 ++++++++-
tests/holdem/handHistoryFilter.test.js | 123 ++++++++++++++++++
5 files changed, 250 insertions(+), 2 deletions(-)
create mode 100644 tests/holdem/handHistoryFilter.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index ae5cbb5..6c07afd 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -141,7 +141,13 @@
Hands
+
+
+
+
+
Resolved pots are logged here, in case a chip count is disputed later.
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index a41d98e..0df9471 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1957,11 +1957,52 @@ button,
color: #e07b7b;
}
+.handHistoryFilterRow {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.45rem;
+}
+
+.handHistoryFilter {
+ flex: 1 1 auto;
+ min-width: 0;
+ /* 16px or iOS zooms the whole page on focus, which on a phone at the table throws
+ the felt off screen mid-hand. Every other field in this panel is held at 16px
+ for the same reason. */
+ font-size: 16px;
+ padding: 0.35rem 0.5rem;
+ background: #141414;
+ border: 1px solid #2c2c2c;
+ border-radius: 6px;
+ color: #e6e6e6;
+}
+
+.handHistoryFilter::placeholder {
+ color: #8a8a8a;
+}
+
+.handHistoryFilter:focus-visible {
+ outline: 2px solid #d8b46a;
+ outline-offset: 1px;
+}
+
+.handHistoryCount {
+ flex: 0 0 auto;
+ font-size: 0.72rem;
+ color: #8a8a8a;
+ font-variant-numeric: tabular-nums;
+}
+
#handHistoryList {
display: flex;
flex-direction: column;
gap: 0.4rem;
- max-height: 260px;
+ /* Was a flat 260px, which is about four and a half rows — the same four and a half
+ whether the dealer is on a phone or a 27" screen with room for twenty. The floor
+ keeps it honest on a short window; the ceiling stops the panel from running away
+ on a tall one and pushing everything below it out of reach. */
+ max-height: clamp(200px, 42vh, 560px);
overflow-y: auto;
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 95dd018..a87c244 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -591,15 +591,33 @@ document.addEventListener("DOMContentLoaded", () => {
if (!ok) return;
HoldemState.clearHistory(state);
+ Render.historyFilter = "";
+ document.getElementById("handHistoryFilter").value = "";
hooks.save(state);
Render.renderAll();
Toast.show("Hand history cleared.", { duration: 2500 });
});
+ // The filter is how the dealer looks at the log, not something about the table, so
+ // it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
+ // tab. Clearing the history clears it too, since there is nothing left to filter.
+ document.getElementById("handHistoryFilter").addEventListener("input", e => {
+ Render.historyFilter = e.target.value;
+ Render.renderHandHistory();
+ });
+
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
+ // Always the whole night. A filter is for reading the panel; an export that
+ // quietly dropped the hands scrolled past would be a record with holes in it,
+ // and nothing on the CSV would say so.
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
{ notes: state.notes });
+ if (Render.historyFilter) {
+ Toast.show(`Exported all ${state.handHistory.length} hands, not just the ${
+ state.handHistory.filter(h => Render.handMatchesFilter(h, Render.historyFilter)).length} shown.`,
+ { duration: 3500 });
+ }
});
// The load happens on the first line of this file, long before there's a Toast to
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index b413975..100a5a8 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -967,9 +967,50 @@ const Render = {
newest ? newest.handNumber : "",
newest ? newest.timestamp : "",
newest ? newest.pot : "",
+ // Typing in the filter changes the list without changing the history, and
+ // the redraw is skipped when the signature matches — leave this out and the
+ // box takes input and does nothing.
+ this.historyFilter || "",
].join("|");
},
+ // What the dealer opens the log for is one hand: the one somebody is arguing
+ // about. A night runs to sixty or more, five at a time in the panel, so finding it
+ // meant scrolling and reading every row. A name, an amount, or a hand number
+ // narrows it instead.
+ //
+ // Matching is deliberately loose. "fay" finds Fay whatever case it's typed in;
+ // "190" finds a $190 pot, a $190 payout, or hand 190, because a dealer half
+ // remembering an amount shouldn't have to know which of those they mean; "#12"
+ // pins it to the hand number, for when they do.
+ handMatchesFilter(entry, query) {
+ if (!query) return true;
+ const term = query.trim().toLowerCase();
+ if (!term) return true;
+
+ const handNumber = String(entry.handNumber || "");
+ if (term.startsWith("#")) return handNumber.startsWith(term.slice(1));
+
+ // Only what the row itself shows. Matching the hand's full ledger as well was
+ // the first attempt, on the reasoning that a dealer might hunt for the hand
+ // that broke somebody by the loser's name — but the ledger holds everyone who
+ // put a chip in, so at a table where nobody folds it matched every hand.
+ // Measured on a seven-handed night: "Fay" returned 60 of 60. Searching text the
+ // panel doesn't display is a filter that looks broken even when it's right.
+ const labels = (entry.payouts || []).map(p => String(p.label || "").toLowerCase()).join(" ");
+ if (labels.includes(term)) return true;
+
+ // Amounts, matched on the digits so "$190", "190" and "1,90" all behave.
+ const digits = term.replace(/[^0-9.]/g, "");
+ if (digits) {
+ const amounts = [entry.pot, ...(entry.payouts || []).map(p => p.amount)]
+ .map(a => String(Money.round(Number(a) || 0)));
+ if (amounts.some(a => a.startsWith(digits))) return true;
+ if (handNumber === digits) return true;
+ }
+ return false;
+ },
+
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
// full re-render runs after every fold, call and raise. The log only changes when a
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
@@ -988,10 +1029,29 @@ const Render = {
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
+ const query = this.historyFilter || "";
+ const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
+
+ // How much of the night is being hidden. Without it a filter that matches one
+ // hand looks exactly like a table that only played one, which is the sort of
+ // thing a dealer reads wrong at 2am with somebody leaning over the rail.
+ const count = document.getElementById("handHistoryCount");
+ if (count) {
+ count.hidden = !query || history.length === 0;
+ count.textContent = `${shown.length} of ${history.length}`;
+ }
+ const noMatch = document.getElementById("handHistoryNoMatch");
+ if (noMatch) {
+ noMatch.hidden = !(query && history.length > 0 && shown.length === 0);
+ noMatch.textContent = `No hand matches “${query.trim()}”. The log still has all ${history.length}.`;
+ }
+ const filterBox = document.getElementById("handHistoryFilter");
+ if (filterBox) filterBox.hidden = history.length === 0;
+
// Built off-document so the panel is laid out once instead of once per row.
const fragment = document.createDocumentFragment();
const template = document.getElementById("handHistoryRowTemplate");
- history.forEach(entry => {
+ shown.forEach(entry => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
diff --git a/tests/holdem/handHistoryFilter.test.js b/tests/holdem/handHistoryFilter.test.js
new file mode 100644
index 0000000..5bc33c3
--- /dev/null
+++ b/tests/holdem/handHistoryFilter.test.js
@@ -0,0 +1,123 @@
+// Finding one hand in a night of them.
+//
+// What the log is actually opened for is a single hand — the one somebody at the rail
+// is arguing about. A night runs to sixty or more, five at a time in the panel, and
+// there was no way to ask for one: the dealer scrolled and read every row.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Render } = require("./load");
+
+const hand = (handNumber, pot, payouts, ledger = []) => ({
+ handNumber, pot, timestamp: Date.now(), payouts, ledger,
+});
+
+// A night as the tool records it: labels carry the cards the wizard was shown.
+const NIGHT = [
+ hand(1, 40, [{ label: "Ann (A♠ K♠)", amount: 40 }], [{ name: "Ann", in: 20 }, { name: "Ben", in: 20 }]),
+ hand(2, 190, [{ label: "Fay", amount: 190 }], [{ name: "Fay", in: 95 }, { name: "Gus", in: 95 }]),
+ hand(12, 19, [{ label: "Ben", amount: 19 }], [{ name: "Ben", in: 10 }, { name: "Ann", in: 9 }]),
+ hand(120, 76, [{ label: "Cal", amount: 38 }, { label: "Dee", amount: 38 }], [{ name: "Cal" }, { name: "Dee" }]),
+];
+
+const found = query => NIGHT.filter(h => Render.handMatchesFilter(h, query)).map(h => h.handNumber);
+
+describe("no filter", () => {
+ test("shows the whole night", () => {
+ assert.deepEqual(found(""), [1, 2, 12, 120]);
+ assert.deepEqual(found(" "), [1, 2, 12, 120]);
+ assert.deepEqual(found(null), [1, 2, 12, 120]);
+ });
+});
+
+describe("by name", () => {
+ test("finds the hand somebody won", () => {
+ assert.deepEqual(found("Fay"), [2]);
+ });
+
+ test("case doesn't matter — nobody types a capital at 2am", () => {
+ assert.deepEqual(found("fay"), [2]);
+ assert.deepEqual(found("FAY"), [2]);
+ });
+
+ test("a name inside a label with cards on it still matches", () => {
+ // The wizard writes the label as "Ann (A♠ K♠)", so the name is a substring
+ // rather than the whole cell.
+ assert.deepEqual(found("ann"), [1]);
+ });
+
+ test("matches what the row shows, not everyone who was in the hand", () => {
+ // Gus is in hand 2's ledger — he paid for it — but the row names the winner,
+ // and searching text the panel doesn't display is a filter that looks broken
+ // even when it's right. Measured on a seven-handed night where nobody folded,
+ // matching the ledger returned 60 of 60 for every name at the table.
+ assert.deepEqual(found("gus"), []);
+ });
+
+ test("a split pot is found by either winner", () => {
+ assert.deepEqual(found("cal"), [120]);
+ assert.deepEqual(found("dee"), [120]);
+ });
+
+ test("a name nobody has finds nothing rather than everything", () => {
+ assert.deepEqual(found("zed"), []);
+ });
+});
+
+describe("by amount", () => {
+ test("finds a pot", () => {
+ assert.deepEqual(found("190"), [2]);
+ });
+
+ test("a dollar sign and commas are ignored", () => {
+ assert.deepEqual(found("$190"), [2]);
+ assert.deepEqual(found("$1,90"), [2], "typed badly, still found");
+ });
+
+ test("finds a payout that isn't the whole pot", () => {
+ assert.deepEqual(found("38"), [120], "half of a chopped $76");
+ });
+
+ test("a partial amount narrows rather than missing", () => {
+ // Half remembering "something around forty" should still get there.
+ assert.deepEqual(found("4"), [1], "$40");
+ assert.deepEqual(found("19"), [2, 12], "the $190 pot and the $19 one");
+ });
+
+ test("a bare number is an exact hand number, not a prefix of one", () => {
+ // Amounts match on their leading digits, because half a remembered figure is
+ // still a lead. Hand numbers don't: typing 12 to mean the hand is a dealer who
+ // knows which hand they want, and dragging 120 and 128 along with it is noise.
+ // "#12" is there for when they do want the prefix.
+ assert.ok(!found("12").includes(120));
+ });
+});
+
+describe("by hand number", () => {
+ test("a bare number matches the hand number as well as amounts", () => {
+ assert.ok(found("12").includes(12));
+ });
+
+ test("a hash pins it to the hand number and nothing else", () => {
+ assert.deepEqual(found("#12"), [12, 120], "12 and 120 both start with 12");
+ assert.deepEqual(found("#2"), [2], "and not the $190 pot of hand 2's neighbours");
+ });
+
+ test("a hash with a number nobody played finds nothing", () => {
+ assert.deepEqual(found("#7"), []);
+ });
+});
+
+describe("odd input can't break it", () => {
+ test("punctuation on its own doesn't match everything", () => {
+ assert.deepEqual(found("$"), [], "no digits and no letters left to match on");
+ assert.deepEqual(found("#"), [1, 2, 12, 120], "an empty hand number is a prefix of them all");
+ });
+
+ test("an entry with pieces missing doesn't throw", () => {
+ const broken = { handNumber: 5, pot: 10 };
+ assert.doesNotThrow(() => Render.handMatchesFilter(broken, "ann"));
+ assert.equal(Render.handMatchesFilter(broken, "ann"), false);
+ assert.equal(Render.handMatchesFilter(broken, "10"), true, "the pot is still findable");
+ });
+});
From 9f3d0dce4b4ee08965fa7ccf46043ef5504706c8 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 22:53:14 -0600
Subject: [PATCH 31/55] Call who shows first at the showdown, instead of
leaving the dealer to remember
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every showdown that isn't a fold needs somebody to turn over first, and it is the one
call at the table nobody volunteers for — everyone would rather see a hand than show
one. The tool held both facts the rule needs, who last raised and who sits left of
the button, and said neither. The dealer worked it out from memory while five people
waited, every hand.
The action bar now names the seat and the reason above the resolve button:
Dee shows first — last to bet or raise.
Ben shows first — checked round, first left of the button.
Dee shows first — last to put money in; nobody could bet the river.
The reason is there so the call can be repeated to whoever argues with it, which is
half of what makes it stick.
Three cases, in the order the rule asks them. Somebody bet or raised on the final
round: they show. Nobody did: first live player left of the button. Nobody *could*,
because the chips were already in: it falls to whoever last put money in, on whichever
street that was — "no bet on the final round" isn't a choice anybody made when there
was no round to bet in, so the seat left of the button hasn't earned it.
Aggression is tracked per street and per hand, because those two answers differ
exactly when it matters. A short all-in counts: not reopening the betting is a rule
about who may raise, and says nothing about who shows. Posting a blind doesn't — a
hand limped and checked all the way down has no aggressor at all, which is the case
that goes to the button.
A matching ruling goes in Common rulings, since the dealer will be asked.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 16 ++
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 10 +
.../wwwroot/js/casino/holdem/actionBar.js | 51 ++++-
.../wwwroot/js/casino/holdem/betting.js | 53 +++++
.../wwwroot/js/casino/holdem/state.js | 9 +-
tests/holdem/showdownOrder.test.js | 202 ++++++++++++++++++
6 files changed, 334 insertions(+), 7 deletions(-)
create mode 100644 tests/holdem/showdownOrder.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 6c07afd..617e5ff 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -399,6 +399,22 @@
+
+
Who turns their hand over first
+
+ If there was a bet on the river, the last player to bet or raise shows
+ first. If the river was checked all the way round, it's the first live
+ player left of the button. Nobody has to be asked twice — a player may
+ always turn their hand over early if they want to.
+
+
+ When everyone is all-in before the river there was no final round to
+ lead, so it falls to whoever last put money in, on whichever street that
+ was. The action bar names the seat and the reason at every showdown, so
+ the call is already made before anyone asks.
+
+
+
The odd chip in a split pot
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 0df9471..eabcf70 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1957,6 +1957,16 @@ button,
color: #e07b7b;
}
+/* The showdown call, sat above the resolve button. Loud enough to be read across a
+ table at a glance — this is something the dealer says out loud — but not so loud it
+ competes with the button underneath it. */
+.showdownCall {
+ margin: 0 0 0.45rem;
+ text-align: center;
+ font-size: 0.82rem;
+ color: #f0d98c;
+}
+
.handHistoryFilterRow {
display: flex;
align-items: center;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index afba098..ede7c5b 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -691,13 +691,41 @@ const ActionBar = {
return wrap;
},
+ // "Dee shows first — last to raise." The call the dealer has to make at every
+ // showdown and that nobody at the table volunteers for, said out loud with the
+ // reason attached so it can be repeated to whoever argues with it.
+ showdownCall() {
+ const call = Betting.showdownOrder(this.state);
+ if (!call) return null;
+
+ const who = HoldemState.displayName(this.state.seats[call.index], call.index);
+ const because = {
+ aggressor: "last to bet or raise",
+ // Semicolon, not a second dash: the sentence already spends its dash on
+ // "shows first —", and two of them in one line reads as a stutter.
+ allInAggressor: "last to put money in; nobody could bet the river",
+ leftOfButton: "checked round, first left of the button",
+ }[call.reason];
+
+ const line = document.createElement("p");
+ line.className = "showdownCall";
+ line.textContent = `${who} shows first — ${because}.`;
+ return line;
+ },
+
renderStreetDone() {
- const row = document.createElement("div");
- row.className = "actionButtons";
+ const wrap = document.createElement("div");
const isRiver = this.state.street === "river";
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
+ if (isRiver) {
+ const call = this.showdownCall();
+ if (call) wrap.appendChild(call);
+ }
+
+ const row = document.createElement("div");
+ row.className = "actionButtons";
row.appendChild(this.button(label, "actionAdvance", () => {
if (isRiver) {
this.hooks.resolvePot();
@@ -705,22 +733,33 @@ const ActionBar = {
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
}));
+ wrap.appendChild(row);
- return row;
+ return wrap;
},
renderHandOver() {
- const row = document.createElement("div");
- row.className = "actionButtons";
+ const wrap = document.createElement("div");
const live = Betting.liveSeats(this.state);
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
+ // Two or more still in with the betting finished is a showdown however it was
+ // arrived at — including everyone all-in on the flop, where the cards go on
+ // their backs and the order still decides who turns over first.
+ if (!winner) {
+ const call = this.showdownCall();
+ if (call) wrap.appendChild(call);
+ }
+
+ const row = document.createElement("div");
+ row.className = "actionButtons";
row.appendChild(this.button(
winner ? `Award pot to ${winner}` : "Resolve pot",
"actionAdvance",
() => this.hooks.resolvePot()
));
- return row;
+ wrap.appendChild(row);
+ return wrap;
},
};
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 3f78c8e..6dfebba 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -276,6 +276,11 @@ const Betting = {
if (newBet > previousBet) {
const increase = this.round(newBet - previousBet);
+ // Aggression, whether or not it was a full raise — a short all-in doesn't
+ // reopen the betting, but the player who made it is still the last one who
+ // put money in, and at the showdown they are still the one who shows first.
+ state.streetAggressorIndex = index;
+ state.handAggressorIndex = index;
// What a full raise would have had to be: at least as much again as the
// last one, or the big blind if there hasn't been a raise yet.
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
@@ -337,6 +342,10 @@ const Betting = {
state.currentBet = 0;
state.lastRaiseSize = 0;
+ // Whoever led this street stops being the answer the moment the next one opens.
+ // The hand's aggressor is deliberately left alone: it's the fallback for a
+ // river nobody could bet on.
+ state.streetAggressorIndex = null;
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
state.street = this.STREETS[nextStreetIndex] || "river";
@@ -424,6 +433,11 @@ const Betting = {
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
+ // Posting a blind isn't aggression — a hand limped and checked all the way
+ // down has no aggressor at all, which is exactly the case the showdown rule
+ // sends to the seat left of the button.
+ state.streetAggressorIndex = null;
+ state.handAggressorIndex = null;
// Deal around anyone with no chips, before anything else reads the table.
//
@@ -504,6 +518,45 @@ const Betting = {
: this.nextToAct(state, bbIndex);
},
+ // Who turns their hand over first.
+ //
+ // A call the dealer makes at every showdown that isn't a fold, and one nobody at
+ // the table volunteers for — everyone would rather see a hand than show one. The
+ // tool knew who last raised and who sits left of the button and said neither, so
+ // the dealer was working it out from memory while five people waited.
+ //
+ // Three cases, in the order the rule asks them:
+ // * somebody bet or raised on the final round — they show first;
+ // * the round was checked through — first live player left of the button;
+ // * nobody could bet, because the chips were already in — the last player who
+ // did put money in, on whichever round that was.
+ //
+ // Returns null when there's nothing to call: one player left, or none.
+ showdownOrder(state) {
+ const live = this.liveSeats(state);
+ if (live.length < 2) return null;
+
+ const seatOf = index =>
+ Number.isInteger(index) && live.some(s => s.index === index) ? index : null;
+
+ const streetAggressor = seatOf(state.streetAggressorIndex);
+ if (streetAggressor !== null) {
+ return { index: streetAggressor, reason: "aggressor" };
+ }
+
+ // Nobody could have bet this round, so "no bet on the final round" isn't a
+ // choice anybody made and the seat left of the button hasn't earned the
+ // obligation. It falls to whoever last put money in.
+ if (!this.canStillBet(state)) {
+ const handAggressor = seatOf(state.handAggressorIndex);
+ if (handAggressor !== null) return { index: handAggressor, reason: "allInAggressor" };
+ }
+
+ const order = HoldemState.liveOrderFromButton(state)
+ .filter(index => live.some(s => s.index === index));
+ return order.length > 0 ? { index: order[0], reason: "leftOfButton" } : null;
+ },
+
// Winners get their share put back in front of them.
creditWinnings(state, payouts) {
payouts.forEach(payout => {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 888034f..a950af9 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -86,6 +86,12 @@ const HoldemState = {
// seating as it stands, because by then the people it needs to know about
// are exactly the ones who have got up and gone.
postedBlinds: null,
+ // Who last bet or raised, on this street and anywhere in the hand. Kept
+ // apart because the showdown rule needs both: normally the aggressor of the
+ // final betting round shows first, but a round nobody could bet in — chips
+ // already all-in — falls back to the last one who could.
+ streetAggressorIndex: null,
+ handAggressorIndex: null,
// Player ids that were dealt in the last hand actually dealt. What makes a
// seat recognisable as having *just* joined — new to the table, or back
// from sitting out — which is the whole question behind owing a blind.
@@ -173,7 +179,8 @@ const HoldemState = {
// them in rather than making the dealer reset a table mid-session.
const defaults = this.createDefault();
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
- "actingSeatIndex", "blindOverride", "postedBlinds"]
+ "actingSeatIndex", "blindOverride", "postedBlinds",
+ "streetAggressorIndex", "handAggressorIndex"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
diff --git a/tests/holdem/showdownOrder.test.js b/tests/holdem/showdownOrder.test.js
new file mode 100644
index 0000000..cf6ff53
--- /dev/null
+++ b/tests/holdem/showdownOrder.test.js
@@ -0,0 +1,202 @@
+// Who turns their hand over first.
+//
+// A call the dealer makes at every showdown that isn't a fold, and one nobody at the
+// table volunteers for — everyone would rather see a hand than show one. The tool knew
+// who last raised and who sits left of the button and said neither.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { HoldemState, Betting, makeTable } = require("./load");
+
+const NAMES = ["Ann", "Ben", "Cal", "Dee"];
+const table = (dealerSeatIndex = 0, stack = 200) => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, dealerSeatIndex });
+ NAMES.forEach((name, i) => { state.seats[i].name = name; });
+ Betting.beginHand(state);
+ return state;
+};
+
+const call = state => {
+ const answer = Betting.showdownOrder(state);
+ return answer && { who: state.seats[answer.index].name, reason: answer.reason };
+};
+
+// Everybody left just calls whatever is in front of them, then the street closes.
+const checkRound = state => {
+ let guard = 0;
+ while (state.actingSeatIndex !== null && guard++ < 12) {
+ const index = state.actingSeatIndex;
+ if (Betting.toCall(state, state.seats[index]) > 0) Betting.call(state, index);
+ else Betting.check(state, index);
+ }
+ Betting.closeStreet(state);
+};
+
+describe("a bet on the river", () => {
+ test("the last player to raise shows first", () => {
+ const state = table(0); // blinds Ben and Cal
+ checkRound(state); // pre-flop
+ checkRound(state); // flop
+ checkRound(state); // turn
+
+ Betting.raiseTo(state, state.actingSeatIndex, 20);
+ const raiser = state.seats.findIndex(p => Betting.committed(p) === 20);
+ while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
+
+ assert.deepEqual(call(state), { who: state.seats[raiser].name, reason: "aggressor" });
+ });
+
+ test("a re-raise moves it to the last one, not the first", () => {
+ const state = table(0);
+ checkRound(state); checkRound(state); checkRound(state);
+
+ const first = state.actingSeatIndex;
+ Betting.raiseTo(state, first, 20);
+ const second = state.actingSeatIndex;
+ Betting.raiseTo(state, second, 60);
+ while (state.actingSeatIndex !== null) {
+ const i = state.actingSeatIndex;
+ if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
+ }
+
+ assert.equal(call(state).who, state.seats[second].name);
+ });
+
+ test("a short all-in still shows first, even though it doesn't reopen the betting", () => {
+ // The rule about reopening is about who may raise. It says nothing about who
+ // shows, and the player who shoved is still the last one who put money in.
+ const state = table(0);
+ checkRound(state); checkRound(state); checkRound(state);
+
+ const bettor = state.actingSeatIndex;
+ Betting.raiseTo(state, bettor, 40);
+ const shortStack = state.actingSeatIndex;
+ // Over the $40 but only by $15, where a full raise would need $40 more. Going
+ // *under* the bet would be a short call, not a short raise, and wouldn't make
+ // them the aggressor at all.
+ state.seats[shortStack].stack = 55;
+ Betting.allIn(state, shortStack);
+ assert.equal(Betting.committed(state.seats[shortStack]), 55, "raised, if not by enough");
+ while (state.actingSeatIndex !== null) {
+ const i = state.actingSeatIndex;
+ if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
+ }
+
+ assert.equal(call(state).who, state.seats[shortStack].name);
+ });
+});
+
+describe("a river checked through", () => {
+ test("the first live player left of the button shows first", () => {
+ const state = table(0); // button Ann
+ checkRound(state); checkRound(state); checkRound(state); checkRound(state);
+
+ assert.deepEqual(call(state), { who: "Ben", reason: "leftOfButton" });
+ });
+
+ test("an earlier street's raiser doesn't carry over to the river", () => {
+ // The rule is about the *final* round. Leading the flop and then checking the
+ // river doesn't oblige anyone.
+ const state = table(0);
+ checkRound(state); // pre-flop
+ Betting.raiseTo(state, state.actingSeatIndex, 20); // leads the flop
+ while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
+ Betting.closeStreet(state);
+ checkRound(state); // turn
+ checkRound(state); // river
+
+ assert.equal(call(state).reason, "leftOfButton");
+ });
+
+ test("it skips a folded seat rather than naming somebody who isn't in the hand", () => {
+ const state = table(0); // button Ann, so Ben would be first
+ checkRound(state); checkRound(state); checkRound(state);
+ Betting.fold(state, 1); // Ben gets out on the river
+ while (state.actingSeatIndex !== null) {
+ const i = state.actingSeatIndex;
+ if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
+ }
+ Betting.closeStreet(state);
+
+ assert.equal(call(state).who, "Cal", "the next one round who is still in");
+ });
+});
+
+describe("everyone all-in before the river", () => {
+ test("the last player who could put money in shows first", () => {
+ // There was no final round to lead, so "checked round" isn't a choice anybody
+ // made and the seat left of the button hasn't earned the obligation.
+ const state = table(0, 30);
+ checkRound(state); // pre-flop
+
+ const shover = state.actingSeatIndex;
+ Betting.allIn(state, shover);
+ while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
+ Betting.closeStreet(state);
+
+ assert.equal(Betting.canStillBet(state), false, "nobody has chips left to bet");
+ assert.deepEqual(call(state), { who: state.seats[shover].name, reason: "allInAggressor" });
+ });
+
+ test("a hand where everyone got in for the blind and nobody raised falls to the button", () => {
+ // Two-dollar stacks against a two-dollar blind: everyone is all-in by calling,
+ // and calling is not aggression. Nobody chose to lead, so nobody is on the hook
+ // and it goes back to the seat left of the button.
+ const state = table(0, 2);
+ checkRound(state);
+
+ assert.equal(Betting.canStillBet(state), false, "nobody has a chip behind");
+ assert.equal(state.handAggressorIndex, null, "and nobody ever raised");
+ const answer = call(state);
+ assert.ok(answer, "there is still a showdown to order");
+ assert.equal(answer.reason, "leftOfButton");
+ });
+});
+
+describe("when there is nothing to call", () => {
+ test("one player left isn't a showdown", () => {
+ const state = table(0);
+ [1, 2, 3].forEach(i => Betting.fold(state, i));
+ assert.equal(Betting.showdownOrder(state), null);
+ });
+
+ test("an aggressor who has since folded doesn't get named", () => {
+ // Can't happen in play — you don't fold after raising unopposed — but the
+ // index is a number on the state and a reset table could hold a stale one.
+ const state = table(0);
+ checkRound(state); checkRound(state); checkRound(state); checkRound(state);
+ state.streetAggressorIndex = 1;
+ state.seats[1].folded = true;
+
+ const answer = call(state);
+ assert.notEqual(answer.who, "Ben");
+ assert.equal(answer.reason, "leftOfButton");
+ });
+
+ test("an index pointing at no seat at all is ignored", () => {
+ const state = table(0);
+ checkRound(state); checkRound(state); checkRound(state); checkRound(state);
+ state.streetAggressorIndex = 99;
+ assert.equal(call(state).reason, "leftOfButton");
+ });
+});
+
+describe("the tracking resets when it should", () => {
+ test("a new hand starts with nobody on the hook", () => {
+ const state = table(0);
+ checkRound(state); checkRound(state); checkRound(state);
+ Betting.raiseTo(state, state.actingSeatIndex, 20);
+ assert.notEqual(state.handAggressorIndex, null);
+
+ HoldemState.startNextHand(state);
+ Betting.beginHand(state);
+ assert.equal(state.streetAggressorIndex, null);
+ assert.equal(state.handAggressorIndex, null, "last night's raise isn't this hand's business");
+ });
+
+ test("posting a blind is not aggression", () => {
+ const state = table(0);
+ assert.equal(state.streetAggressorIndex, null);
+ assert.equal(state.handAggressorIndex, null);
+ });
+});
From e9bc9afcdb284f9727919cd4a815575368b63a4f Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 23:08:42 -0600
Subject: [PATCH 32/55] Seat a player when the dealer names one, and stop
hiding the bar on a full table
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first thing anybody does with this tool is seat the table, and it didn't work.
Measured on a fresh table: click a seat, type a name, repeat six times — six names on
the felt, six seats still not in the hand, and no action bar at all. No "post blinds
and deal", no explanation, nothing to press. Six people at the table and a blank
screen with no way forward.
Three things were wrong and each hid the next.
Naming a chair and dealing that chair in were separate acts. Putting a name on an
empty chair is somebody sitting down, so it now deals them in. Only a chair that was
empty — no name and nobody in it: renaming someone who is sitting out is a correction,
not an arrival, and dealing them back in over a typo would be the tool overruling the
dealer.
The action bar hid itself whenever no seat was live, which was written for an empty
felt and caught a named one too. A table with names on it is not an empty table; it's
a table the dealer needs to be told something about.
And the seat editor never redrew the bar. It refreshes narrowly on purpose — a full
render on every keystroke would take the name field out from under the dealer
mid-word — but the bar answers "is there a table here at all", and seating somebody is
exactly what changes that answer. Even ticking "In hand" by hand left the bar hidden
until something else happened to redraw. Verified the fix keeps focus and the caret
where they were, typing a character at a time.
For the states that can still reach it — a table where everyone was sat out, a save
from before naming seated people — the prompt names them and offers "Deal in all 6"
rather than a hunt through six seat editors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/actionBar.js | 38 ++++-
.../wwwroot/js/casino/holdem/render.js | 28 ++++
tests/holdem/load.js | 5 +
tests/holdem/seatingTheTable.test.js | 134 ++++++++++++++++++
4 files changed, 204 insertions(+), 1 deletion(-)
create mode 100644 tests/holdem/seatingTheTable.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index ede7c5b..17b18a8 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -79,8 +79,14 @@ const ActionBar = {
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
// still holds during setup, because a seated player is a live seat whether or not
// the betting has been opened. An empty table stays uncluttered.
+ //
+ // A *named* table is not an empty one, though, and that was the hole: name six
+ // people without dealing any of them in and the bar hid itself, leaving a screen
+ // with six players on it, no way forward and nothing saying why. Anyone with a
+ // name on the felt is somebody the dealer has to be told about.
isRunning() {
- return Betting.liveSeats(this.state).length > 0;
+ return Betting.liveSeats(this.state).length > 0 ||
+ this.state.seats.some(p => (p.name || "").trim());
},
render() {
@@ -598,6 +604,36 @@ const ActionBar = {
}
line.classList.add("dealPromptBlocked");
wrap.appendChild(line);
+
+ // Named seats that aren't in the hand. Naming a chair now seats whoever is
+ // in it, so reaching this takes something else — a table where everyone was
+ // sat out, or a save from before that was true — but the way out of it
+ // should still be one tap rather than a hunt through six seat editors.
+ const benched = this.state.seats
+ .map((player, index) => ({ player, index }))
+ .filter(({ player }) => (player.name || "").trim() && !player.inHand)
+ .map(({ index }) => index);
+
+ if (benched.length > 0) {
+ const aside = document.createElement("p");
+ aside.className = "dealPromptLine dealPromptAside dealPromptDebt";
+ const text = document.createElement("span");
+ text.textContent = benched.length === 1
+ ? `${listNames(benched)} is sitting out.`
+ : `${listNames(benched)} are sitting out.`;
+ aside.appendChild(text);
+ aside.appendChild(this.button(
+ benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
+ "dealPromptDebtBtn",
+ () => this.commit("dealing everybody in", () => {
+ benched.forEach(index => {
+ this.state.seats[index].inHand = true;
+ this.state.seats[index].folded = false;
+ });
+ })
+ ));
+ wrap.appendChild(aside);
+ }
return wrap;
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 100a5a8..836d0bd 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -524,13 +524,41 @@ const Render = {
this.updatePotBadge();
this.renderSeatTotal(form, player);
this.refreshPuck(index);
+ // The editor deliberately refreshes narrowly rather than redrawing everything —
+ // a full render on every keystroke would take the name field out from under the
+ // dealer mid-word. But the action bar answers "is there a table here at all",
+ // and seating somebody is exactly what changes that answer. Without this it
+ // stayed hidden until something else happened to redraw: two players seated, a
+ // dealable hand, and a blank bar until the page was reloaded.
+ //
+ // Safe to do on every keystroke: the bar is small, it's rebuilt on every action
+ // anyway, and nothing inside it can hold focus while the sidebar has it.
+ if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
+ // Putting a name on an empty chair is somebody sitting down, so they get
+ // dealt in. It used to be two separate acts, and the second one was easy to
+ // miss: a dealer setting up typed six names, got six seats that still
+ // weren't in the hand, and the action bar hid itself because by its reckoning
+ // nobody was playing. Six people at the table and a blank screen.
+ //
+ // Only for a chair that was empty — no name and not dealt in. Renaming
+ // somebody who is sitting out is a correction, not an arrival, and dealing
+ // them back in over a typo would be the tool overruling the dealer.
+ const wasEmpty = !player.name.trim() && !player.inHand;
player.name = nameInput.value;
+ if (wasEmpty && player.name.trim()) {
+ player.inHand = true;
+ player.folded = false;
+ const inHandBox = form.querySelector(".inHandCheckbox");
+ if (inHandBox) inHandBox.checked = true;
+ const foldBox = form.querySelector(".foldCheckbox");
+ if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
+ }
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
diff --git a/tests/holdem/load.js b/tests/holdem/load.js
index 2e7452a..e2caca8 100644
--- a/tests/holdem/load.js
+++ b/tests/holdem/load.js
@@ -29,6 +29,10 @@ globalThis.Render = Render;
const HoldemState = require(path.join(SRC, "state.js"));
globalThis.HoldemState = HoldemState;
+// actionBar.js after state, since its own module-level code reaches for neither but
+// its methods reach for both. Loaded last so nothing it touches is still undefined.
+const ActionBar = load("actionBar");
+
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -81,6 +85,7 @@ module.exports = {
SettleUp,
Betting,
HoldemState,
+ ActionBar,
makeTable,
chipsOnTable,
contributions,
diff --git a/tests/holdem/seatingTheTable.test.js b/tests/holdem/seatingTheTable.test.js
new file mode 100644
index 0000000..87ae837
--- /dev/null
+++ b/tests/holdem/seatingTheTable.test.js
@@ -0,0 +1,134 @@
+// Sitting people down, which is the first thing anybody does with the tool.
+//
+// Naming a chair and dealing that chair in were two separate acts and the second was
+// easy to miss. A dealer setting up typed six names, got six seats that still weren't
+// in the hand, and the action bar hid itself because by its reckoning nobody was
+// playing: six people at the table and a blank screen with no way forward.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { HoldemState, Betting, ActionBar, makeTable } = require("./load");
+
+const emptyTable = (seatCount = 10) => {
+ const state = HoldemState.createDefault();
+ HoldemState.resizeSeats(state, seatCount);
+ return state;
+};
+
+const bar = state => Object.assign(Object.create(ActionBar), { state });
+
+describe("the bar knows the difference between an empty table and a named one", () => {
+ test("a table nobody has touched gets no bar", () => {
+ assert.equal(bar(emptyTable()).isRunning(), false, "an empty felt stays uncluttered");
+ });
+
+ test("a table with names on it gets one, dealt in or not", () => {
+ const state = emptyTable();
+ state.seats[0].name = "Ann";
+ state.seats[1].name = "Ben";
+
+ assert.equal(Betting.liveSeats(state).length, 0, "nobody is in the hand");
+ assert.equal(bar(state).isRunning(), true, "but there are people at the table to say something to");
+ });
+
+ test("whitespace isn't a player", () => {
+ const state = emptyTable();
+ state.seats[0].name = " ";
+ assert.equal(bar(state).isRunning(), false);
+ });
+
+ test("a table that is playing still gets one", () => {
+ assert.equal(bar(makeTable({ seatCount: 6, seated: [0, 1] })).isRunning(), true);
+ });
+});
+
+describe("naming an empty chair sits somebody down", () => {
+ // What the seat editor's name field does, as the rule it applies rather than
+ // through the DOM: a chair with no name and nobody in it is empty, and putting a
+ // name on it is somebody arriving.
+ const nameSeat = (player, name) => {
+ const wasEmpty = !player.name.trim() && !player.inHand;
+ player.name = name;
+ if (wasEmpty && player.name.trim()) { player.inHand = true; player.folded = false; }
+ };
+
+ test("an empty chair gets dealt in", () => {
+ const state = emptyTable();
+ nameSeat(state.seats[0], "Ann");
+ assert.equal(state.seats[0].inHand, true);
+ assert.equal(state.seats[0].folded, false);
+ });
+
+ test("six names is six players, not six benched seats", () => {
+ const state = emptyTable();
+ state.defaultStack = 200; // a table that has told the tool what a buy-in is
+ ["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => nameSeat(state.seats[i], name));
+
+ assert.equal(state.seats.filter(p => p.inHand).length, 6);
+ assert.equal(bar(state).isRunning(), true);
+ assert.ok(Betting.blindPreview(state), "and there's a hand to deal");
+ });
+
+ test("renaming somebody who is sitting out doesn't deal them back in", () => {
+ // A correction, not an arrival. Dealing them in over a typo would be the tool
+ // overruling a dealer who sat them out on purpose.
+ const state = emptyTable();
+ state.seats[0].name = "Ann";
+ state.seats[0].inHand = false;
+
+ nameSeat(state.seats[0], "Anne");
+ assert.equal(state.seats[0].inHand, false, "still sitting out, now spelled right");
+ });
+
+ test("clearing a name doesn't sit anybody down", () => {
+ const state = emptyTable();
+ nameSeat(state.seats[0], "Ann");
+ nameSeat(state.seats[0], "");
+ assert.equal(state.seats[0].name, "");
+ });
+
+ test("a chair already in the hand is left alone", () => {
+ const state = makeTable({ seatCount: 6, seated: [0] });
+ state.seats[0].folded = true;
+ nameSeat(state.seats[0], "Ann");
+ assert.equal(state.seats[0].folded, true, "mid-hand a fold is not undone by typing");
+ });
+});
+
+describe("the way back from a table where everybody is sat out", () => {
+ test("there are named seats to offer", () => {
+ // Can't be reached by naming any more, but a table where everyone was sat out
+ // — or a save from before naming seated people — still lands here.
+ const state = emptyTable();
+ ["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; });
+
+ const benched = state.seats
+ .map((player, index) => ({ player, index }))
+ .filter(({ player }) => (player.name || "").trim() && !player.inHand)
+ .map(({ index }) => index);
+
+ assert.deepEqual(benched, [0, 1, 2]);
+ assert.equal(Betting.blindPreview(state), null, "no hand to deal until they're in");
+ });
+
+ test("dealing them all in makes a hand possible", () => {
+ const state = emptyTable();
+ ["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].stack = 100; });
+
+ state.seats.forEach(player => {
+ if ((player.name || "").trim()) { player.inHand = true; player.folded = false; }
+ });
+
+ assert.equal(Betting.liveSeats(state).length, 3);
+ assert.ok(Betting.blindPreview(state), "the table can be dealt");
+ });
+
+ test("an unnamed chair is never swept in by it", () => {
+ const state = emptyTable();
+ state.seats[0].name = "Ann";
+ state.seats.forEach(player => {
+ if ((player.name || "").trim()) { player.inHand = true; }
+ });
+ assert.equal(state.seats.filter(p => p.inHand).length, 1, "empty chairs stay empty");
+ });
+});
From c70cb150dbeb4255dc87230b548161b366661303 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 23:20:13 -0600
Subject: [PATCH 33/55] Stop telling a brand-new table its players are broke,
and give it a way out
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Following on from seating the table: a dealer who names six people and hasn't yet
said what a buy-in is gets told
Nobody at the table has chips left. Add a buy-in to deal.
with no buttons on the bar at all. Both halves are wrong for that table. "Left" says
the chips were had and lost, which sends the dealer looking for a rebuy — and a
blocked prompt with nothing to press is a dead end, when the fix is one field in the
sidebar the message never pointed at.
A table where nobody has ever bought in is now told what is actually true, and given
the way out:
Nobody has bought in yet. Set what everyone starts with, or give each seat
their chips. [ Set starting stack ]
The button opens Table Setup, scrolls the starting stack into view and selects it, so
the next thing typed is the answer. A seat with no stack of its own falls back to the
table default, which is why one number is enough to seat everybody's chips at once.
A table that genuinely busted out is a different situation and still says so, with no
starting-stack button — there the fix is a rebuy, not a default.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/actionBar.js | 31 +++++++++++-
tests/holdem/seatingTheTable.test.js | 47 +++++++++++++++++++
2 files changed, 77 insertions(+), 1 deletion(-)
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 17b18a8..caf7f93 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -593,10 +593,19 @@ const ActionBar = {
// thing: "only one player is dealt in" is wrong when four are sitting there
// and three of them are just out of chips.
const withChips = seated - broke.length;
+ // "Nobody has chips left" is the wrong story for a table that never had
+ // any. A dealer who has just named six people and not yet said what a
+ // buy-in is gets told their players are busted, which sends them looking
+ // for a rebuy button instead of the one field that fixes it.
+ const nobodyHasEverBoughtIn = this.state.seats.every(p =>
+ !(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
+
if (broke.length > 0 && withChips < 2) {
line.textContent = withChips === 1
? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
- : "Nobody at the table has chips left. Add a buy-in to deal.";
+ : nobodyHasEverBoughtIn
+ ? "Nobody has bought in yet. Set what everyone starts with, or give each seat their chips."
+ : "Nobody at the table has chips left. Add a buy-in to deal.";
} else {
line.textContent = seated === 0
? "Nobody is dealt in yet. Tap a seat to add a player."
@@ -605,6 +614,26 @@ const ActionBar = {
line.classList.add("dealPromptBlocked");
wrap.appendChild(line);
+ // A blocked prompt with nothing to press is a dead end, and this was the
+ // one a first-time dealer hits: six players named, a sentence saying they
+ // have no chips, and no button anywhere on the bar. The starting stack is a
+ // single field in the sidebar and the message never pointed at it.
+ if (nobodyHasEverBoughtIn && seated > 0) {
+ const row = document.createElement("div");
+ row.className = "actionButtons";
+ row.appendChild(this.button("Set starting stack", "actionDeal", () => {
+ const field = document.getElementById("defaultStackInput");
+ if (!field) return;
+ const panel = field.closest("details");
+ if (panel) panel.open = true;
+ field.scrollIntoView({ block: "center", behavior: "smooth" });
+ field.focus();
+ field.select();
+ }));
+ wrap.appendChild(row);
+ return wrap;
+ }
+
// Named seats that aren't in the hand. Naming a chair now seats whoever is
// in it, so reaching this takes something else — a table where everyone was
// sat out, or a save from before that was true — but the way out of it
diff --git a/tests/holdem/seatingTheTable.test.js b/tests/holdem/seatingTheTable.test.js
index 87ae837..67580d0 100644
--- a/tests/holdem/seatingTheTable.test.js
+++ b/tests/holdem/seatingTheTable.test.js
@@ -132,3 +132,50 @@ describe("the way back from a table where everybody is sat out", () => {
assert.equal(state.seats.filter(p => p.inHand).length, 1, "empty chairs stay empty");
});
});
+
+describe("a table where nobody has bought in", () => {
+ // Different from a table that has busted out, and the difference is the whole
+ // point: one needs a starting stack named, the other needs a rebuy. Telling a
+ // dealer their brand-new players have "no chips left" sends them hunting for the
+ // wrong thing.
+ const neverBoughtIn = state => state.seats.every(p =>
+ !(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
+
+ test("six freshly named players have never bought in", () => {
+ const state = emptyTable();
+ ["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].inHand = true;
+ });
+
+ assert.equal(neverBoughtIn(state), true);
+ assert.equal(Betting.blindPreview(state), null, "and there is no hand to deal");
+ });
+
+ test("a table that has busted out is not the same thing", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
+ state.seats.forEach(p => { p.buyIns = 200; p.stack = 0; });
+
+ assert.equal(neverBoughtIn(state), false, "they had chips — they lost them");
+ });
+
+ test("one player still holding chips is not a never-bought-in table either", () => {
+ const state = emptyTable();
+ ["Ann", "Ben"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
+ state.seats[0].stack = 50;
+
+ assert.equal(neverBoughtIn(state), false);
+ });
+
+ test("naming a starting stack is enough to deal", () => {
+ // The one field the message now points at. A seat with no stack of its own
+ // falls back to the table default, so setting it seats everybody's chips at
+ // once — which is why it's the right thing to send the dealer to.
+ const state = emptyTable();
+ ["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
+ assert.equal(Betting.blindPreview(state), null);
+
+ state.defaultStack = 200;
+ assert.ok(Betting.blindPreview(state), "one number and the table can play");
+ });
+});
From 8efc14f61f964f2a2e87676708433c5dc9230e76 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 23:36:14 -0600
Subject: [PATCH 34/55] Show the banker the amount to count out, not just
whether the player is up
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The standings had a column headed "Cash out" and it did not hold an amount to pay.
It held the difference against the player's buy-in. Measured on a table where Ann
bought in for $200 and sits behind $340:
PLAYER NET CASH OUT
Ann $0 +$140
She is handed $340. The column called "Cash out" says $140, under a heading that
invites reading it as the payout, at the exact moment somebody is counting money onto
the table. The number a banker's hands actually follow — what the player is holding —
was computed all along and written to the CSV, and never shown on screen.
Three columns now, and they say what they are: Player, Chips, Up/down. Chips is a
plain amount and never signed, because it is a quantity of money to hand over. Up/down
keeps the sign and the colour, because it is a result. The two differ by exactly the
buy-in, which is asserted rather than assumed.
Chips counts what is out in the middle for the hand in progress as still theirs, for
the same reason the up/down figure does: the panel is read mid-hand constantly and a
stack that drops by the size of a bet looks like money going missing.
Net — won less paid in, from the hand log — comes off the screen to make room. It is
the weakest of the three: it only moves when a pot is resolved through the wizard, so
at a table where the dealer settles pots by hand it reads $0 all night beside two
columns that don't, and up/down already answers "how are they doing". It stays in the
CSV export, which loses nothing.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 6 +-
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 29 +++++++---
.../wwwroot/js/casino/holdem/render.js | 12 +++-
.../wwwroot/js/casino/holdem/state.js | 8 +++
tests/holdem/bankAudit.test.js | 55 +++++++++++++++++++
5 files changed, 97 insertions(+), 13 deletions(-)
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 617e5ff..64c9b18 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -110,8 +110,9 @@
PlayerInWon
- Net
- Cash out
+ Net
+ Chips
+ Up/down
@@ -270,6 +271,7 @@
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index eabcf70..fe8e893 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1816,31 +1816,44 @@ button,
white-space: nowrap;
}
-/* The per-hand columns are detail: how the night's swings happened. They're the
- first thing to go when there isn't room, because Net and Cash out are what the
- banker is actually reading. */
+/* The per-hand columns are detail: how the night's swings happened. They're the first
+ to go when there isn't room, and Net goes with them — it only moves when a pot is
+ resolved through the wizard, so on a table where the dealer settles pots by hand it
+ reads $0 all night beside two columns that don't.
+
+ What survives at every width is the pair the banker actually works from: what the
+ player is holding, and whether that leaves them up or down. */
.standingsIn,
.standingsWon,
+.standingsNet,
.standingsInHead,
-.standingsWonHead {
+.standingsWonHead,
+.standingsNetHead {
display: none;
}
@container (min-width: 30rem) {
.standingsRow {
- grid-template-columns: minmax(5rem, 1fr) auto auto auto auto;
+ grid-template-columns: minmax(5rem, 1fr) auto auto auto auto auto;
}
.standingsIn,
.standingsWon,
+ .standingsNet,
.standingsInHead,
- .standingsWonHead {
+ .standingsWonHead,
+ .standingsNetHead {
display: block;
}
}
-/* The settle-up column is the one a banker reads while counting out cash, so it
- carries the weight the per-hand columns don't. */
+/* The chips column is the one a banker's hands follow while counting money onto the
+ table, so it carries the weight. Up/down is read once, at the end. */
+.standingsChips {
+ font-weight: 600;
+ color: #e6e6e6;
+}
+
.standingsCashOut {
font-weight: 600;
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 836d0bd..5a2755f 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -818,9 +818,15 @@ const Render = {
netEl.classList.toggle("netUp", row.net > 0);
netEl.classList.toggle("netDown", row.net < 0);
- // What they'd actually be handed if the night ended now: chips in front of
- // them less the cash they gave the banker. This was computed and written to
- // the CSV all along, but never shown to the person doing the paying out.
+ // The cash to count out, which is a plain amount and never signed — this
+ // is the one the banker's hands follow. It sat only in the CSV: on screen
+ // the column headed "Cash out" held the difference against their buy-in
+ // instead, so a player up $140 on a $340 stack read as "$140" at the exact
+ // moment somebody was counting money onto the table.
+ node.querySelector(".standingsChips").textContent = Money.format(row.chipsInFront);
+
+ // And whether that leaves them up or down, which is the question the rest
+ // of the table asks and the one the old column was actually answering.
const cashEl = node.querySelector(".standingsCashOut");
cashEl.textContent = Money.formatSigned(row.cashPosition);
cashEl.classList.toggle("netUp", row.cashPosition > 0);
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index a950af9..01b4b80 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -683,6 +683,14 @@ const HoldemState = {
net: Money.round(t.won - t.in),
boughtIn,
chipsNow,
+ // Everything still theirs: the stack in front of them plus whatever
+ // is out in the middle for the hand in progress. This is the number
+ // a banker counts out when somebody leaves, and it was the one
+ // figure the standings never showed — the column called "Cash out"
+ // held the difference against their buy-in, so a player up $140 on
+ // a $340 stack read as "$140" at the exact moment cash changed
+ // hands.
+ chipsInFront: Money.round(chipsNow + inFrontOfThem),
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
notes,
};
diff --git a/tests/holdem/bankAudit.test.js b/tests/holdem/bankAudit.test.js
index eb2f2b2..9223d5b 100644
--- a/tests/holdem/bankAudit.test.js
+++ b/tests/holdem/bankAudit.test.js
@@ -167,3 +167,58 @@ describe("does the cash box cover the chips on the table", () => {
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
+
+describe("the number the banker counts out", () => {
+ // The standings had two signed columns and neither was an amount to pay. A player
+ // up $140 on a $340 stack read "$140" under a heading saying "Cash out", at the
+ // exact moment somebody was counting money onto the table.
+ test("chips in front is what they are holding, not what they are up", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 340 });
+ state.seats[0].name = "Ann";
+ state.seats[0].buyIns = 200;
+ state.seats[1].buyIns = 200;
+ state.seats[1].stack = 60;
+
+ const rows = HoldemState.sessionStandings(state);
+ const ann = rows.find(r => r.name === "Ann");
+ assert.equal(ann.chipsInFront, 340, "the cash to count out");
+ assert.equal(ann.cashPosition, 140, "and separately, that they are up $140");
+ });
+
+ test("a player who is down still has chips to be handed", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 60 });
+ state.seats[0].name = "Cal";
+ state.seats.forEach(p => { p.buyIns = 200; });
+
+ const cal = HoldemState.sessionStandings(state).find(r => r.name === "Cal");
+ assert.equal(cal.chipsInFront, 60, "they leave with $60 of chips");
+ assert.equal(cal.cashPosition, -140, "having lost $140 of the $200 they brought");
+ });
+
+ test("chips still out in the middle are counted as theirs", () => {
+ // Cashing out mid-hand isn't a thing, but the panel is read mid-hand constantly
+ // and a stack that drops by the size of a bet looks like money going missing.
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
+ state.seats[0].name = "Dee";
+ state.seats.forEach(p => { p.buyIns = 200; });
+ Betting.beginHand(state);
+ Betting.raiseTo(state, 0, 50);
+
+ const dee = HoldemState.sessionStandings(state).find(r => r.name === "Dee");
+ assert.equal(dee.chipsInFront, 200, "stack plus what's in the middle for them");
+ assert.equal(dee.cashPosition, 0, "so they are still level");
+ });
+
+ test("chips and up/down always differ by exactly the buy-in", () => {
+ const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 100 });
+ [340, 150, 60, 250].forEach((chips, i) => {
+ state.seats[i].stack = chips;
+ state.seats[i].buyIns = 200 + i * 50;
+ });
+
+ HoldemState.sessionStandings(state).forEach(row => {
+ assert.equal(Money.round(row.chipsInFront - row.boughtIn), row.cashPosition,
+ `${row.name}'s two columns should tell one story`);
+ });
+ });
+});
From 09eac536b5203ff91051378818a9d44ba87e3d8e Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 23:51:41 -0600
Subject: [PATCH 35/55] Make the pot button offer a pot-sized raise
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The raise pad worked its quick amounts out as "the current bet plus the pot as it
stands". That leaves out the raiser's own call, which is in the pot before the raise
is measured off it, so every amount came out short. Measured pre-flop at 1/2:
offered: Min $4 ½ pot $4 Pot $5
correct: Min $4 ½ pot $4.50 Pot $7
A pot raise there is: call the $2, which makes the pot $5, then raise $5. And the
half-pot was so short it clamped up to the minimum and sat next to the Min button
showing the same $4 twice, which is how it went unnoticed — it looked like a table
where those two happened to coincide.
"Pot" is not a suggestion. It is a definite number players say out loud, and a dealer
tapping the button that claims to be it should be recording what was actually said.
The rule moves into Betting.potRaiseTo, next to the other rules and testable on its
own, rather than living as arithmetic inside a DOM renderer:
raise to = the current bet + (pot + what they owe) × fraction
Facing no bet it comes out as a plain pot-sized bet, because both the bet and the
amount owed are zero. Facing a bet it comes out as three times the bet plus the dead
money, which is the shape a player checks it against.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/actionBar.js | 11 +-
.../wwwroot/js/casino/holdem/betting.js | 21 ++++
tests/holdem/potRaise.test.js | 113 ++++++++++++++++++
3 files changed, 142 insertions(+), 3 deletions(-)
create mode 100644 tests/holdem/potRaise.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index caf7f93..79070b5 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -430,7 +430,6 @@ const ActionBar = {
const player = this.state.seats[index];
const stack = Betting.stackOf(this.state, player);
const maxTo = Betting.round(Betting.committed(player) + stack);
- const pot = Betting.potSoFar(this.state);
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
// what this player can actually put in, which is what the Min button should
// offer. `legalMin` is what the rules require — for a short stack the two are
@@ -446,10 +445,16 @@ const ActionBar = {
const quick = document.createElement("div");
quick.className = "raiseQuick";
+ // The pot amounts come from Betting, which knows that a pot raise is measured
+ // off the pot as it stands *after* the caller's own call goes in. The pad used
+ // to work it out here as "the bet plus the pot", which left the call out and
+ // made both amounts short: pre-flop at 1/2 it offered "Pot $5" where a pot
+ // raise is $7, and a half-pot that clamped up to the minimum and sat next to
+ // the Min button showing the same $4 twice.
const options = [
{ label: "Min", value: minTo },
- { label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
- { label: "Pot", value: Betting.round(this.state.currentBet + pot) },
+ { label: "½ pot", value: Betting.potRaiseTo(this.state, index, 0.5) },
+ { label: "Pot", value: Betting.potRaiseTo(this.state, index, 1) },
{ label: "All-in", value: maxTo },
];
options.forEach(option => {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 6dfebba..fa4a5a3 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -518,6 +518,27 @@ const Betting = {
: this.nextToAct(state, bbIndex);
},
+ // What a pot-sized raise actually is, as a "raise to" total.
+ //
+ // Not "the bet plus the pot as it stands". The raiser calls first, and their call
+ // is in the pot before the raise is measured off it, so the pot being matched is
+ // the one that exists after the call:
+ //
+ // raise to = the current bet + (pot + what they owe) × fraction
+ //
+ // Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then
+ // raise $5. Leaving the call out understates every one of these, and players say
+ // "pot" out loud meaning a definite number.
+ //
+ // Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is
+ // zero, so it comes out as the pot, which is the right answer there too.
+ potRaiseTo(state, index, fraction = 1) {
+ const player = state.seats[index];
+ if (!player) return 0;
+ const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player));
+ return this.round((state.currentBet || 0) + potAfterCall * fraction);
+ },
+
// Who turns their hand over first.
//
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
diff --git a/tests/holdem/potRaise.test.js b/tests/holdem/potRaise.test.js
new file mode 100644
index 0000000..8a3e262
--- /dev/null
+++ b/tests/holdem/potRaise.test.js
@@ -0,0 +1,113 @@
+// What a pot-sized raise actually is.
+//
+// The raise pad worked it out as "the current bet plus the pot as it stands", which
+// leaves out the fact that the raiser calls first and their call is in the pot before
+// the raise is measured off it. Every quick amount came out short — pre-flop at 1/2 it
+// offered "Pot $5" where a pot raise is $7. Players say "pot" out loud meaning a
+// definite number, and a dealer tapping the button that claims to be it should record
+// what was actually said.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Betting, makeTable } = require("./load");
+
+describe("pre-flop at 1/2", () => {
+ const dealt = () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
+ Betting.beginHand(state);
+ return state;
+ };
+
+ test("a pot raise from the first player to act is $7", () => {
+ // Call the $2, which makes the pot $5, then raise $5.
+ const state = dealt();
+ assert.equal(Betting.potSoFar(state), 3, "the blinds");
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7);
+ });
+
+ test("half pot is $4.50, which is above the $4 minimum rather than equal to it", () => {
+ // The old arithmetic gave $3.50, which clamped up to the minimum and put the
+ // same $4 on two buttons side by side.
+ const state = dealt();
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5);
+ assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5),
+ Betting.minRaiseTo(state), "the two quick buttons say different things");
+ });
+
+ test("the small blind owes less, so their pot raise is smaller", () => {
+ // They have $1 in already: call $1, pot becomes $4, raise $4, total $6.
+ const state = dealt();
+ const { sbIndex } = require("./load").HoldemState.getBlindSeats(state);
+ assert.equal(Betting.toCall(state, state.seats[sbIndex]), 1);
+ assert.equal(Betting.potRaiseTo(state, sbIndex), 6);
+ });
+
+ test("the big blind owes nothing, so it is just the pot", () => {
+ const state = dealt();
+ const { bbIndex } = require("./load").HoldemState.getBlindSeats(state);
+ assert.equal(Betting.toCall(state, state.seats[bbIndex]), 0);
+ assert.equal(Betting.potRaiseTo(state, bbIndex), 5, "the $2 bet plus the $3 pot");
+ });
+});
+
+describe("after the flop", () => {
+ const flopWithBet = () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 500, dealerSeatIndex: 0 });
+ Betting.beginHand(state);
+ while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
+ Betting.closeStreet(state);
+ return state;
+ };
+
+ test("a pot raise facing a bet is three times the bet plus the dead money", () => {
+ const state = flopWithBet();
+ const potBefore = Betting.potSoFar(state); // $6 carried over
+ const bettor = state.actingSeatIndex;
+ Betting.raiseTo(state, bettor, 10);
+
+ const raiser = state.actingSeatIndex;
+ // Call $10, pot becomes $26, raise $26 → $36. Which is 3×10 + 6.
+ assert.equal(Betting.potRaiseTo(state, raiser), 36);
+ assert.equal(Betting.potRaiseTo(state, raiser), 3 * 10 + potBefore);
+ });
+
+ test("half pot facing that bet is $23", () => {
+ const state = flopWithBet();
+ Betting.raiseTo(state, state.actingSeatIndex, 10);
+ // Call $10, pot becomes $26, raise $13 → $23.
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 23);
+ });
+
+ test("facing no bet at all it is a plain pot-sized bet", () => {
+ const state = flopWithBet();
+ assert.equal(state.currentBet, 0, "nobody has bet the flop");
+ assert.equal(Betting.toCall(state, state.seats[state.actingSeatIndex]), 0);
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), Betting.potSoFar(state));
+ });
+});
+
+describe("it stays sane at the edges", () => {
+ test("a seat that doesn't exist gets zero rather than throwing", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
+ assert.equal(Betting.potRaiseTo(state, 99), 0);
+ });
+
+ test("the amount is always at least a call", () => {
+ // A raise-to below the current bet would be nonsense to offer. Every fraction
+ // from a tenth up lands at or above what the player owes.
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
+ Betting.beginHand(state);
+ const index = state.actingSeatIndex;
+ [0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
+ assert.ok(Betting.potRaiseTo(state, index, fraction) >= state.currentBet,
+ `${fraction} of the pot should still cover the bet`);
+ });
+ });
+
+ test("cent-sized blinds don't drift", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 20, smallBlind: 0.05, bigBlind: 0.1 });
+ Betting.beginHand(state);
+ // Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35.
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
+ });
+});
From 40fa16920b0d820c7e54ef543b25d3c874016672 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 00:05:26 -0600
Subject: [PATCH 36/55] Offer bet sizes the table's chips can actually make
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Half the pot lands on a half chip constantly. Pre-flop at 1/2 the honest half-pot is
$4.50, and the smallest chip in the default legend is a dollar — the tool's own chip
breakdown calls it "4×$1, $0.50 no chip covers". The button offered it anyway, so the
dealer rounded it themselves every single time.
The pot-derived quick amounts now round up to something the table can build. Up, not
down or nearest: these are bet sizes, a pot-sized bet that has been shaved is no
longer a pot-sized bet, and rounding up cannot drop an amount below a legal minimum
the way rounding the other way can.
Only when the legend describes this game. A smallest chip larger than the big blind
means the denominations were never set for these stakes, and rounding a 5c/10c table
up to whole dollars would be the tool doing real damage on the strength of a field
somebody forgot to fill in. Those tables keep exact amounts, and the wrong legend
stays visible rather than being quietly applied. A table with no legend at all is
told nothing about chips and so has nothing applied either.
Verified across five tables: default dollar chips at 1/2 give $4/$5/$7, cent chips at
5c/10c keep $0.20/$0.23/$0.35, five-dollar chips at 5/10 give $20/$25/$35, and both
the stale-legend and no-legend tables are left exact.
The minimum raise is deliberately not rounded. It is what the rules require, and
moving it to suit the chip tray would change what is legal rather than what is
convenient.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/betting.js | 17 ++++-
.../wwwroot/js/casino/holdem/money.js | 23 ++++++
tests/holdem/potRaise.test.js | 75 ++++++++++++++++++-
3 files changed, 110 insertions(+), 5 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index fa4a5a3..baa240e 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -532,11 +532,26 @@ const Betting = {
//
// Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is
// zero, so it comes out as the pot, which is the right answer there too.
+ // Rounded up to something the table's chips can actually build. Half the pot is
+ // constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest
+ // chip in the default legend is a dollar, so the button offered a number the
+ // dealer would have to round themselves every single time.
+ //
+ // Only when the legend describes this game. A smallest chip *larger* than the big
+ // blind means the denominations were never set for these stakes, and rounding a
+ // 5c/10c table up to whole dollars would be the tool doing real damage on the
+ // strength of a field somebody forgot to fill in. Left exact there, and the wrong
+ // legend stays visible rather than being quietly applied.
potRaiseTo(state, index, fraction = 1) {
const player = state.seats[index];
if (!player) return 0;
const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player));
- return this.round((state.currentBet || 0) + potAfterCall * fraction);
+ const exact = this.round((state.currentBet || 0) + potAfterCall * fraction);
+
+ const step = Money.fromCents(Money.chipStepCents(state.chipLegend));
+ const bigBlind = Number(state.bigBlind) || 0;
+ if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact;
+ return Money.upToChip(exact, state.chipLegend);
},
// Who turns their hand over first.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js
index 9f120b2..f0fe135 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js
@@ -63,6 +63,29 @@ const Money = {
// Anything the smallest chip can't make comes back as `remainder` rather than being
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
// problem for the dealer to solve, not a rounding error to hide.
+ // The smallest chip the table has said it plays with, in cents. Zero when no
+ // denominations are set, which means the tool has been told nothing about what
+ // chips exist and shouldn't pretend otherwise.
+ chipStepCents(legend) {
+ const values = (legend || [])
+ .map(chip => this.toCents(chip.value))
+ .filter(cents => cents > 0);
+ return values.length > 0 ? Math.min(...values) : 0;
+ },
+
+ // Rounds an amount up to something the table can actually build out of its chips.
+ //
+ // Up rather than down or nearest: these are bet sizes, and a pot-sized bet that
+ // has been shaved is no longer a pot-sized bet. Rounding up also can't drop an
+ // amount below a legal minimum, which rounding the other way can.
+ upToChip(amount, legend) {
+ const step = this.chipStepCents(legend);
+ if (step <= 0) return this.round(amount);
+ const cents = this.toCents(amount);
+ if (cents <= 0) return this.round(amount);
+ return this.fromCents(Math.ceil(cents / step) * step);
+ },
+
chipBreakdown(amount, legend) {
let cents = this.toCents(amount);
if (cents <= 0) return { chips: [], remainder: 0 };
diff --git a/tests/holdem/potRaise.test.js b/tests/holdem/potRaise.test.js
index 8a3e262..d72a995 100644
--- a/tests/holdem/potRaise.test.js
+++ b/tests/holdem/potRaise.test.js
@@ -9,7 +9,7 @@
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
-const { Betting, makeTable } = require("./load");
+const { Betting, Money, makeTable } = require("./load");
describe("pre-flop at 1/2", () => {
const dealt = () => {
@@ -25,11 +25,13 @@ describe("pre-flop at 1/2", () => {
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7);
});
- test("half pot is $4.50, which is above the $4 minimum rather than equal to it", () => {
+ test("half pot is $5 — $4.50 rounded up to something the chips can make", () => {
// The old arithmetic gave $3.50, which clamped up to the minimum and put the
- // same $4 on two buttons side by side.
+ // same $4 on two buttons side by side. The honest half-pot is $4.50, and the
+ // smallest chip in the default legend is a dollar, so the button would have
+ // offered an amount the dealer had to round themselves every single time.
const state = dealt();
- assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5);
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5),
Betting.minRaiseTo(state), "the two quick buttons say different things");
});
@@ -111,3 +113,68 @@ describe("it stays sane at the edges", () => {
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
});
});
+
+describe("the amounts are ones the table can actually build", () => {
+ const CENTS = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
+ const DOLLARS = [{ value: 1 }, { value: 5 }, { value: 25 }, { value: 100 }];
+
+ const table = (legend, smallBlind, bigBlind, stack = 200) => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, smallBlind, bigBlind });
+ state.chipLegend = legend;
+ Betting.beginHand(state);
+ return state;
+ };
+
+ test("a half-pot that lands on a half chip is rounded up, not offered as it is", () => {
+ const state = table(DOLLARS, 1, 2);
+ // Honestly $4.50. No dollar chip makes fifty cents.
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
+ assert.equal(Money.chipBreakdown(5, DOLLARS).remainder, 0);
+ });
+
+ test("up rather than down — a shaved pot bet is not a pot bet", () => {
+ const state = table(DOLLARS, 1, 2);
+ assert.ok(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5) > 4.5);
+ });
+
+ test("an amount the chips already make is left exactly alone", () => {
+ const state = table(DOLLARS, 1, 2);
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7, "the pot raise was already whole");
+ });
+
+ test("a cent table keeps its cents", () => {
+ const state = table(CENTS, 0.05, 0.1);
+ // Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35, which cent chips make.
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
+ assert.equal(Money.chipBreakdown(0.35, CENTS).remainder, 0);
+ });
+
+ test("a legend that plainly isn't for these stakes is not applied", () => {
+ // Dollar chips on a 5c/10c game means somebody never filled the legend in.
+ // Rounding those blinds up to whole dollars would be the tool doing real
+ // damage on the strength of a stale field, so the amounts stay exact and the
+ // wrong legend stays visible.
+ const state = table(DOLLARS, 0.05, 0.1);
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
+ });
+
+ test("a table with no legend at all gets exact amounts", () => {
+ const state = table([], 1, 2);
+ assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5);
+ });
+
+ test("every fraction comes out buildable, and never shaved", () => {
+ const state = table(DOLLARS, 1, 2);
+ const index = state.actingSeatIndex;
+ const potAfterCall = Betting.potSoFar(state) + Betting.toCall(state, state.seats[index]);
+
+ [0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
+ const value = Betting.potRaiseTo(state, index, fraction);
+ const exact = state.currentBet + potAfterCall * fraction;
+ assert.ok(value >= exact, `${fraction} of the pot should never be rounded down`);
+ assert.ok(value - exact < 1, `${fraction} of the pot should round by less than a chip`);
+ assert.equal(Money.chipBreakdown(value, DOLLARS).remainder, 0,
+ `${fraction} of the pot should be buildable from the table's chips`);
+ });
+ });
+});
From 3be93cdadfc797ed89dd483c43d68ae44a32b120 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 00:24:07 -0600
Subject: [PATCH 37/55] Chop a side pot on the screen that worked it out, and
fix Quick Split's contrast
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Quick Split lists the pots when somebody is all-in — main, sides, uncalled — and then
leaves the dealer there. Two players tying for a side pot is ordinary, and working out
each one's share meant switching to the other mode and retyping an amount the screen
had just calculated. Retyping a number with money on it is a transcription error
waiting to happen, and the number was already on screen.
Each pot with two or more people who can win it now carries a Chop, opening underneath
its own row: as many ways as there are contenders by default, a stepper for a chop
between fewer of them, and the same odd-chip and dust wording the main split uses,
because it is the same rule and the dealer shouldn't have to notice they are reading a
different screen. Per-row state, so opening the main pot's chop doesn't disturb what
the side pot below it says. Not offered on the uncalled row, which goes back to one
player, nor on the total.
Separately, this page had never been through the layout audit the dealer table gets
every round, and it was carrying nine contrast failures:
.unevenCurrency 4.11:1 the $ beside every amount typed
.unevenRemoveBtn 3.66:1 the ✕ that deletes a player's entry
.splitFootnote 3.94:1 the instruction for what to do with the pot list
All under the 4.5:1 small-text minimum, the worst of them on a destructive control.
Lifted to pass. The large $ on the even-mode pot input is left alone: at 1.6rem it is
large text, where 3:1 is the bar and 4.11:1 clears it.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/css/casino/quicksplit.css | 87 +++++++++++++++++-
.../wwwroot/js/casino/holdem/quickSplit.js | 88 ++++++++++++++++++-
2 files changed, 170 insertions(+), 5 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
index 582aa85..257c887 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
@@ -143,7 +143,9 @@ button,
}
.unevenCurrency {
- color: #777;
+ /* Was #777 — 4.11:1 on this background, under the 4.5:1 small text needs. It sits
+ against every amount a dealer types, so it is read constantly. */
+ color: #8f8f8f;
}
.unevenAmount {
@@ -184,7 +186,9 @@ button,
height: 44px;
border: none;
background: none;
- color: #6a6a6a;
+ /* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
+ player's entry. A destructive action should not be the faintest thing in view. */
+ color: #9a9a9a;
font-size: 0.85rem;
cursor: pointer;
}
@@ -209,11 +213,86 @@ button,
.unevenPot {
display: flex;
align-items: baseline;
+ /* Wraps so a chop opens underneath its own pot rather than squeezing in beside the
+ eligible-players list and pushing it off the row. */
+ flex-wrap: wrap;
gap: 0.6rem;
padding: 0.3rem 0;
font-size: 0.85rem;
}
+.unevenChopToggle {
+ flex: none;
+ order: 3;
+ /* 44px on touch: it moves money, like everything else that does. */
+ min-height: 32px;
+ padding: 0.2rem 0.6rem;
+ border: 1px solid rgba(216, 180, 106, 0.45);
+ border-radius: 999px;
+ background: rgba(216, 180, 106, 0.1);
+ color: #e3c887;
+ font: inherit;
+ font-size: 0.74rem;
+ cursor: pointer;
+}
+
+.unevenChopToggle:hover {
+ background: rgba(216, 180, 106, 0.18);
+}
+
+.unevenChopToggle:focus-visible {
+ outline: 2px solid #d8b46a;
+ outline-offset: 2px;
+}
+
+.unevenChopDetail {
+ order: 4;
+ flex: 0 0 100%;
+ /* Or the padding is added on top of the 100% basis and the panel sits wider than
+ the row it belongs to — measured 328px inside a 309px row on a phone. */
+ box-sizing: border-box;
+ margin: 0.15rem 0 0.2rem;
+ padding: 0.45rem 0.6rem;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.unevenChopWays {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-bottom: 0.3rem;
+}
+
+.unevenChopWaysLabel {
+ color: #cfcfcf;
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.unevenChopEach {
+ margin: 0;
+ color: #f2f2f2;
+ font-weight: 600;
+}
+
+.unevenChopNote {
+ margin: 0.2rem 0 0;
+ color: #9a9a9a;
+ font-size: 0.76rem;
+}
+
+.unevenChopDust {
+ color: #e0b169;
+}
+
+@media (max-width: 700px), (pointer: coarse) {
+ .unevenChopToggle {
+ min-height: 44px;
+ padding: 0.4rem 0.9rem;
+ }
+}
+
.unevenPot + .unevenPot {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
@@ -510,7 +589,9 @@ button,
.splitFootnote {
margin-top: 1.5rem;
- color: #6f6f6f;
+ /* Was #6f6f6f — 3.94:1. It carries the instruction for what to do with the pot
+ list, which is not a decorative line. */
+ color: #8d8d8d;
font-size: 0.78rem;
line-height: 1.5;
text-align: center;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js
index 3ffbcef..73a4c03 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js
@@ -197,6 +197,80 @@ const QuickSplit = {
});
},
+ // Chopping one of the pots listed above, without leaving the screen that worked it
+ // out. Starts closed and at as many ways as there are players who can win the pot,
+ // which is the common case; the stepper covers a chop between fewer of them, which
+ // is the other one.
+ //
+ // The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
+ // the main pot doesn't change what the side pot below it is saying.
+ addChopControl(row, amount, contenderCount) {
+ let ways = contenderCount;
+ let open = false;
+
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "unevenChopToggle";
+
+ const detail = document.createElement("div");
+ detail.className = "unevenChopDetail";
+ detail.hidden = true;
+
+ const draw = () => {
+ toggle.textContent = open ? "Hide chop" : "Chop";
+ toggle.setAttribute("aria-expanded", String(open));
+ detail.hidden = !open;
+ if (!open) return;
+
+ const result = SplitCalculator.split(amount, ways, this.chip);
+ detail.innerHTML = "";
+
+ const stepper = document.createElement("div");
+ stepper.className = "unevenChopWays";
+ stepper.appendChild(this.makeStepButton("−", () => {
+ if (ways > 2) { ways -= 1; draw(); }
+ }));
+ const label = document.createElement("span");
+ label.className = "unevenChopWaysLabel";
+ label.textContent = `${ways} ways`;
+ stepper.appendChild(label);
+ stepper.appendChild(this.makeStepButton("+", () => {
+ if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); }
+ }));
+ detail.appendChild(stepper);
+
+ const each = document.createElement("p");
+ each.className = "unevenChopEach";
+ each.textContent = `${Money.format(result.each)} each`;
+ detail.appendChild(each);
+
+ // Same wording as the main split, because it is the same rule and a dealer
+ // shouldn't have to notice they're reading a different screen.
+ if (result.valid && result.oddChips > 0) {
+ const odd = document.createElement("p");
+ odd.className = "unevenChopNote";
+ odd.textContent = result.oddChips === 1
+ ? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
+ : `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
+ `first ${result.oddChips} players left of the button`;
+ detail.appendChild(odd);
+ }
+ if (result.valid && result.dust > 0) {
+ const dust = document.createElement("p");
+ dust.className = "unevenChopNote unevenChopDust";
+ dust.textContent =
+ `${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
+ `pick a smaller denomination.`;
+ detail.appendChild(dust);
+ }
+ };
+
+ toggle.addEventListener("click", () => { open = !open; draw(); });
+ draw();
+ row.appendChild(toggle);
+ row.appendChild(detail);
+ },
+
updateUneven() {
const players = this.entries
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
@@ -218,7 +292,7 @@ const QuickSplit = {
}
empty.hidden = true;
- const addRow = (label, amount, who, className) => {
+ const addRow = (label, amount, who, className, chopWays) => {
const row = document.createElement("div");
row.className = `unevenPot ${className || ""}`;
const name = document.createElement("span");
@@ -234,9 +308,19 @@ const QuickSplit = {
row.appendChild(value);
row.appendChild(eligible);
host.appendChild(row);
+
+ // Two players tying for a side pot is ordinary, and working out each one's
+ // share meant copying the amount into the other mode by hand — retyping a
+ // number the screen had just worked out, which is a transcription error
+ // waiting to happen with money on it. The chop happens here instead.
+ //
+ // Only offered where it means something: a pot with at least two people
+ // who can win it. Not the uncalled row, which goes back to one player, and
+ // not the total.
+ if (chopWays >= 2) this.addChopControl(row, amount, chopWays);
};
- pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
+ pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
const total = players.reduce((sum, p) => sum + p.contribution, 0);
From b64da7a7871bca099bb76d86ff1c32c3f05c7a2d Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 00:37:47 -0600
Subject: [PATCH 38/55] Make the layout audit usable inside modals, and stop it
passing what it can't measure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Last round turned up that Quick Split had never been audited. Chasing that, the reason
the dealer table's modals had never been audited either turns out to be the harness,
not neglect: it reported the pot wizard covering the seats as ten faults and the
rulings sheet as seven. A modal covering the page is what a modal is for, and an
instrument that calls that a fault is one nobody can use in those states.
It now finds whatever is open on top and scopes the reachability checks to it — the
overlays, plus the phone seat sheet, which is a body class rather than an element. The
seat-ownership check is skipped outright while a sheet is open: seats behind it aren't
fighting each other for taps, nothing is meant to reach them at all.
Two worse faults came out of proving the fix rather than assuming it. Injecting a 20x14
button into the open wizard, the contrast check caught it and the touch-target check
did not:
* effectiveTarget returns null when elementFromPoint can't find the control, and the
check treated that as a pass. Silent skips are how faults hide — the same lesson as
the aria-hidden and faded-text exemptions before this. Unmeasurable controls are
now reported as warnings.
* The probe failed on anything scrolled out of its container. That was hiding a real
control: "Confirm Winner(s)", the button that pays the pot out, had gone unmeasured
for thirty-six rounds. It is scrolled into view before probing, and passes.
A checkbox inside a label is now measured by its label, since tapping the label is what
activates it. Without that, the dedicated-dealer toggle read as an unprobeable 24px
target when it has a 335x44 label around it.
The tool itself came out clean: no failures and no warnings across the table, the pot
wizard, the rulings sheet and the seat editor, on a real touch context at 44px. The
value here is that those states can be checked at all from now on — verified by
injection, which the fixed check catches and the old one did not.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
tests/holdem/uiAudit.js | 86 +++++++++++++++++++++++++++++++++++++++--
1 file changed, 82 insertions(+), 4 deletions(-)
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 0c1bca8..7056cd6 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -93,10 +93,44 @@ const LayoutAudit = {
return false;
},
+ // The modal that is currently on top, if any.
+ //
+ // Every check below asks "is this thing reachable", and while a sheet or dialog is
+ // open the answer for everything behind it is legitimately no — that is what a
+ // modal is for. Without knowing that, the audit reported the pot wizard covering
+ // the seats as ten faults and the rulings sheet as seven, which is the noise that
+ // made it useless in exactly the states nobody had audited. Now it scopes to what
+ // is actually on top and says so.
+ MODAL_SELECTOR: "#potWizardOverlay, #rulingsOverlay, #seatMenuOverlay, #cardPickerOverlay, " +
+ "#confirmOverlay, #addChipsOverlay, #seatEditorOverlay",
+
+ openModal() {
+ const open = [...document.querySelectorAll(this.MODAL_SELECTOR)]
+ .filter(el => !el.hidden && getComputedStyle(el).display !== "none");
+ // Last in the DOM wins, which is how they stack.
+ return open.length > 0 ? open[open.length - 1] : null;
+ },
+
+ // A seat sheet on a phone isn't an overlay element, it's a body class — but it
+ // covers the felt just the same.
+ modalScope() {
+ const overlay = this.openModal();
+ if (overlay) return overlay;
+ if (document.body.classList.contains("seatSheetOpen")) {
+ return document.getElementById("seatEditorPanel") || null;
+ }
+ return null;
+ },
+
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
const offenders = [];
const warnings = [];
- [...document.querySelectorAll(selector)].forEach(el => {
+ // While something is open on top, only what is inside it can be judged. The
+ // rest of the page is meant to be unreachable.
+ const scope = this.modalScope();
+ const candidates = [...document.querySelectorAll(selector)]
+ .filter(el => !scope || scope.contains(el));
+ candidates.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
@@ -504,10 +538,25 @@ const LayoutAudit = {
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
}
+ const unmeasured = [];
this.visibleControls()
.filter(el => el.type !== "color")
.filter(el => !(exempt && el.matches(exempt)))
.forEach(el => {
+ // A control inside a scrolling sheet can sit below its container's
+ // visible area while still having a viewport rect. elementFromPoint at
+ // its centre then returns the sheet, effectiveTarget gives up, and the
+ // check used to pass it in silence — which is how "Confirm Winner(s)",
+ // the button that pays the pot out, went unmeasured for 36 rounds.
+ // Bring it into view before probing.
+ const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
+ if (scroller && scroller.scrollHeight > scroller.clientHeight) {
+ const box = scroller.getBoundingClientRect();
+ const own = el.getBoundingClientRect();
+ if (own.bottom > box.bottom || own.top < box.top) {
+ el.scrollIntoView({ block: "center" });
+ }
+ }
const rect = el.getBoundingClientRect();
// Drawn big enough is big enough — no need to probe, and probing wide
// elements is where the cap above used to produce nonsense. Seats sit
@@ -515,15 +564,35 @@ const LayoutAudit = {
// measuring their reach would report the crowding as a fault.
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
+ // A checkbox inside a label is tapped by tapping the label, so the
+ // label is the target. Measured rather than probed, because probing
+ // fails on anything scrolled off the viewport — which is how a 24px
+ // checkbox with a 335x44 label around it came up as unprobeable
+ // instead of as the perfectly good target it is.
+ const label = el.closest("label");
+ if (label) {
+ const lr = label.getBoundingClientRect();
+ if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return;
+ }
+
const size = this.effectiveTarget(el);
- if (!size) return;
+ // Still can't measure it. That is not a pass — it is the check failing
+ // to run, and quietly returning here is exactly the kind of exemption
+ // that hides real faults. Say so instead.
+ if (!size) {
+ unmeasured.push(
+ `${this.describe(el)} :: could not be probed` +
+ ` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
+ );
+ return;
+ }
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
offenders.push(
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
);
});
- return { name: `touch targets at least ${minimum}px`, offenders };
+ return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
},
// Tapping the middle of a seat must give you that seat.
@@ -537,6 +606,13 @@ const LayoutAudit = {
// Reported separately from occlusion because the cause is different: not an
// overlay on top of the table, but two pieces of the table fighting over a tap.
seatOwnership() {
+ // Seats behind an open sheet aren't fighting each other for taps — nothing is
+ // meant to reach them at all. Asking the question there produced six offenders
+ // per modal, every one of them the modal doing its job.
+ if (this.modalScope()) {
+ return { name: "a seat's centre belongs to that seat", offenders: [],
+ skipped: "a sheet is open over the table" };
+ }
const seats = [...document.querySelectorAll(".seat")];
const offenders = [];
@@ -617,7 +693,9 @@ const LayoutAudit = {
viewport: `${innerWidth}x${innerHeight}`,
failed: checks.filter(c => c.offenders.length > 0),
// Things worth knowing that aren't faults — see occlusion() on toasts.
- warnings: checks.flatMap(c => c.warnings || []),
+ // Controls a check couldn't probe go here too: not a fault, but not a pass
+ // either, and a silent skip is how faults hide.
+ warnings: checks.flatMap(c => [...(c.warnings || []), ...(c.unmeasured || [])]),
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
clean: checks.every(c => c.offenders.length === 0),
};
From 8a2fc3e540c9b1090c3ca7e078035f1b5497f251 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 00:55:56 -0600
Subject: [PATCH 39/55] Give undo and the card picker's buttons a fingertip to
land on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first thing the repaired audit found, run on a real touch context at 44px: three
controls a dealer taps on a phone that are too small to tap.
.actionUndo 145x30 the "I recorded that wrong" pill
#cardPickerClearBtn 50x40 wipes a player's cards
#cardPickerCloseBtn 50x40 closes the picker
Undo is the worst of them. It is the control a dealer reaches for having just entered
the wrong action with five people waiting, and it was fourteen pixels short of a
fingertip. The picker's two are short because the touch rule that fixes every other
small button is scoped to .panelBody, and the picker is a sheet — it never reached
them. One of them is destructive.
All three now sit at 44px on touch. Nothing changes on a pointer device.
I tried an invisible hit-area pad on undo first, to avoid spending bar height, and the
measurement said it did nothing: the pill clips its overflow to keep a long label on
one line, and giving it a positioning context made it clip the pad too. Growing the
box is the honest fix, and it turned out to cost nothing anyway — the bar goes from
138px to 152px and the felt stays at 366px, because the felt had slack against its
220px floor rather than sitting on it as I had assumed.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index fe8e893..6fc5ec8 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -3138,7 +3138,18 @@ body.hasActionBar #toastHost {
}
.panelBody .holdemBtnSmall,
- .handHistoryActions .holdemBtn {
+ .handHistoryActions .holdemBtn,
+ /* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
+ .panelBody and the picker is a sheet, so it never reached them. Clear wipes a
+ player's cards, which is not a thing to make a dealer aim at. */
+ .cardPickerHeader .holdemBtn,
+ /* Undo was 30px, and it is the control a dealer reaches for having just recorded
+ the wrong thing with five people waiting — the worst place to be fourteen pixels
+ short of a fingertip. Growing the box costs bar height, which comes out of the
+ felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
+ is room for it. A hit-area pad was tried first and does not work here: the pill
+ clips its overflow to keep a long label on one line, which clips the pad too. */
+ .actionUndo {
min-height: 44px;
}
@@ -3525,7 +3536,9 @@ textarea:focus-visible {
.panelBody .holdemBtnSmall,
.handHistoryActions .holdemBtn,
- .toastAction {
+ .toastAction,
+ .cardPickerHeader .holdemBtn,
+ .actionUndo {
min-height: 44px;
}
From 419852a1f4ab2c9e53b8a32fb7a34c00152325fa Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 01:07:13 -0600
Subject: [PATCH 40/55] Let a dealer take the table off the device it's stuck
on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The night lives in one browser's localStorage and nowhere else. A dealer whose phone
dies, or who wants to carry on from the tablet after the break, loses the stacks, the
buy-ins, where the button is and the whole hand log. The CSV export can't bring any of
it back: it records what happened, not where the table stands.
The table can now be written to a file and read back. Round-tripped through the real
UI, a hand in progress comes back mid-hand — blinds posted, the same seat on the
clock, the button where it was.
Loading replaces everything, so it asks first and names both sides rather than saying
"are you sure": how many players are in the file and when it was saved, against how
many players and how many logged hands are about to go. It goes through the same
undoable path as the header buttons, so the toast offers the way back, and the
confirm says so instead of overstating it.
Reading a file goes through exactly the same migration a save from storage does —
load() had that logic inline and it is now a function both call. A second, looser path
for imports is how a format grows two meanings of the same field.
What it refuses, each with something a dealer can act on and never a half-loaded
table: anything that isn't JSON, JSON with no seats in it, a save from some other tool
that happens to carry a `kind`, and a file from a newer version — that last one
because loading it risks reading a field whose meaning has changed, and the dealer
would find out from the money. What it accepts anyway: a bare table with no envelope,
for somebody who copied the storage value out by hand.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 11 ++
.../wwwroot/js/casino/holdem/main.js | 66 ++++++++
.../wwwroot/js/casino/holdem/state.js | 92 ++++++++++-
tests/holdem/tableFile.test.js | 155 ++++++++++++++++++
4 files changed, 317 insertions(+), 7 deletions(-)
create mode 100644 tests/holdem/tableFile.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 64c9b18..a984cab 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -155,6 +155,17 @@
Export CSVClear history
+
+
The table itself
+
+ The night lives in this browser and nowhere else. Save it to a file to carry it
+ to another device, or to have something to come back to if this one dies.
+
+
+ Save table file
+ Load table file
+
+
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index a87c244..6439cf9 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -598,6 +598,72 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show("Hand history cleared.", { duration: 2500 });
});
+ document.getElementById("saveTableBtn").addEventListener("click", () => {
+ const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = HoldemState.backupFileName();
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
+ });
+
+ document.getElementById("loadTableBtn").addEventListener("click", () => {
+ document.getElementById("loadTableInput").click();
+ });
+
+ document.getElementById("loadTableInput").addEventListener("change", async e => {
+ const file = e.target.files && e.target.files[0];
+ // Cleared straight away so picking the same file twice still fires a change.
+ e.target.value = "";
+ if (!file) return;
+
+ let text;
+ try {
+ text = await file.text();
+ } catch {
+ Toast.show("That file couldn't be read.", { duration: 4000 });
+ return;
+ }
+
+ const result = HoldemState.fromBackup(text);
+ if (result.error) {
+ Toast.show(result.error, { duration: 5000 });
+ return;
+ }
+
+ // Loading replaces everything: stacks, buy-ins, the button, the log. Naming
+ // what is about to be lost is the difference between a dealer choosing that
+ // and discovering it — and the current table might be the live one.
+ const here = HoldemState.sessionStandings(state).length;
+ const incoming = HoldemState.sessionStandings(result.table).length;
+ const when = result.savedAt ? new Date(result.savedAt) : null;
+ const saidWhen = when && !Number.isNaN(when.getTime())
+ ? ` It was saved ${when.toLocaleString()}.`
+ : "";
+ const ok = await Dialog.confirm({
+ title: "Replace this table?",
+ message:
+ `The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
+ `Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
+ `${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
+ `Undo will bring it back for a few seconds; after that it is gone.`,
+ confirmLabel: "Load it",
+ danger: true,
+ });
+ if (!ok) return;
+
+ runUndoable("loading a saved table", () => {
+ // Replace the contents rather than the reference: everything on the page
+ // holds a pointer to this object.
+ Object.keys(state).forEach(key => { delete state[key]; });
+ Object.assign(state, result.table);
+ });
+ });
+
// The filter is how the dealer looks at the log, not something about the table, so
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
// tab. Clearing the history clears it too, since there is nothing left to filter.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 01b4b80..fdc7e4a 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -164,13 +164,18 @@ const HoldemState = {
return this.createDefault();
},
- load() {
- let raw = null;
- try {
- raw = localStorage.getItem(this.STORAGE_KEY);
- if (!raw) return this.createDefault();
- const parsed = JSON.parse(raw);
- if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
+ // Everything a table has to be put through before it can be trusted, whichever
+ // direction it arrived from.
+ //
+ // Pulled out of load() so a table read from a file goes through exactly the same
+ // checks as one read from storage. A second, looser path for imports is how a
+ // format grows two meanings of the same field.
+ //
+ // Returns null for anything that isn't a table at all; every other repair is
+ // silent and forward, the way a save from an older version is treated.
+ migrate(parsed) {
+ if (!parsed || !Array.isArray(parsed.seats)) return null;
+ {
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
@@ -215,12 +220,85 @@ const HoldemState = {
});
return parsed;
+ }
+ },
+
+ load() {
+ let raw = null;
+ try {
+ raw = localStorage.getItem(this.STORAGE_KEY);
+ if (!raw) return this.createDefault();
+ const migrated = this.migrate(JSON.parse(raw));
+ if (!migrated) return this.failedLoad(raw);
+ return migrated;
} catch {
// Malformed JSON — a truncated write, another tab, a browser hiccup.
return this.failedLoad(raw);
}
},
+ // ---- Taking the table off this device ------------------------------------
+ //
+ // A night lives in one browser's localStorage and nowhere else. A dealer whose
+ // phone dies, or who wants to carry on from the tablet after the break, loses the
+ // stacks, the buy-ins, where the button is and the whole hand log — and the CSV
+ // export can't bring any of that back, because it records what happened rather
+ // than where the table stands.
+ //
+ // So the table can be written out as a file and read back in. Same shape that goes
+ // into storage, wrapped in enough to recognise it later.
+ BACKUP_KIND: "casino.holdem.table",
+ BACKUP_VERSION: 1,
+
+ toBackup(state) {
+ return JSON.stringify({
+ kind: this.BACKUP_KIND,
+ version: this.BACKUP_VERSION,
+ savedAt: new Date().toISOString(),
+ table: state,
+ }, null, 2);
+ },
+
+ // Reads a file back. Returns { table } on success, or { error } with something a
+ // dealer can act on — never a half-loaded table, because the failure mode there is
+ // a night that looks restored and isn't.
+ fromBackup(text) {
+ let parsed;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ return { error: "That file isn't a saved table — it couldn't be read as JSON." };
+ }
+ if (!parsed || typeof parsed !== "object") {
+ return { error: "That file isn't a saved table." };
+ }
+ // A bare table, from someone who copied the storage value out by hand, is
+ // still a table. The envelope is a convenience, not a password.
+ const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
+ if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
+ return { error: "That file is a save from something else, not a Hold'em table." };
+ }
+ const migrated = this.migrate(table);
+ if (!migrated) {
+ return { error: "That file doesn't have a table in it — no seats were found." };
+ }
+ if (parsed.version > this.BACKUP_VERSION) {
+ // Newer than this build understands. Loading it anyway risks reading a
+ // field that has changed meaning, and the dealer would find out from the
+ // money, so say so instead.
+ return { error: "That table was saved by a newer version of this tool. Update, then load it." };
+ }
+ return { table: migrated, savedAt: parsed.savedAt || null };
+ },
+
+ // What the file is called. Dated, because a dealer who saves twice in a night
+ // should not have to guess which is which.
+ backupFileName(now = new Date()) {
+ const pad = n => String(n).padStart(2, "0");
+ return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
+ `-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
+ },
+
// True once a save has failed. The UI reads this to keep saying so, because a
// toast is a moment and this is a fact about the rest of the night.
saveFailed: false,
diff --git a/tests/holdem/tableFile.test.js b/tests/holdem/tableFile.test.js
new file mode 100644
index 0000000..f01fbec
--- /dev/null
+++ b/tests/holdem/tableFile.test.js
@@ -0,0 +1,155 @@
+// Taking the table off this device.
+//
+// A night lives in one browser's localStorage and nowhere else. A dealer whose phone
+// dies, or who wants to carry on from the tablet after the break, loses the stacks,
+// the buy-ins, where the button is and the whole hand log — and the CSV export can't
+// bring any of that back, because it records what happened rather than where the
+// table stands.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { HoldemState, Betting, makeTable } = require("./load");
+
+const aNight = () => {
+ const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 1 });
+ ["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].buyIns = 200;
+ });
+ state.seats[2].stack = 340;
+ state.notes = "Cal owes the bar $12";
+ Betting.beginHand(state);
+ HoldemState.recordHand(state, [{ id: state.seats[2].id, label: "Cal", amount: 60 }]);
+ return state;
+};
+
+describe("a table written out and read back", () => {
+ test("comes back the same table", () => {
+ const before = aNight();
+ const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
+
+ assert.deepEqual(after.seats.map(p => p.name), before.seats.map(p => p.name));
+ assert.deepEqual(after.seats.map(p => p.stack), before.seats.map(p => p.stack));
+ assert.deepEqual(after.seats.map(p => p.buyIns), before.seats.map(p => p.buyIns));
+ assert.equal(after.dealerSeatIndex, before.dealerSeatIndex);
+ assert.equal(after.handNumber, before.handNumber);
+ assert.equal(after.notes, before.notes);
+ assert.equal(after.handHistory.length, before.handHistory.length);
+ });
+
+ test("keeps the things a CSV can't carry", () => {
+ // The whole reason this exists: an export records what happened, not where the
+ // table stands.
+ const before = aNight();
+ const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
+
+ assert.equal(after.bettingOpen, before.bettingOpen, "a hand in progress is still in progress");
+ assert.deepEqual(after.postedBlinds, before.postedBlinds, "so the next rotation is right");
+ assert.equal(after.street, before.street);
+ assert.deepEqual(after.seats.map(p => p.committed), before.seats.map(p => p.committed));
+ assert.deepEqual(after.chipLegend, before.chipLegend);
+ });
+
+ test("the standings and the bank check agree across the trip", () => {
+ const before = aNight();
+ const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
+
+ assert.deepEqual(HoldemState.sessionStandings(after), HoldemState.sessionStandings(before));
+ assert.deepEqual(HoldemState.bankReconciliation(after), HoldemState.bankReconciliation(before));
+ });
+
+ test("it can be dealt from straight away", () => {
+ const before = aNight();
+ HoldemState.startNextHand(before);
+ const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
+
+ Betting.beginHand(after);
+ assert.equal(after.bettingOpen, true);
+ assert.ok(after.actingSeatIndex !== null, "somebody is on the clock");
+ });
+});
+
+describe("what it refuses", () => {
+ const refuses = (text, because) => {
+ const result = HoldemState.fromBackup(text);
+ assert.ok(result.error, because);
+ assert.equal(result.table, undefined, "and never hands back half a table");
+ };
+
+ test("something that isn't JSON", () => {
+ refuses("not a file", "unreadable");
+ refuses("", "empty");
+ });
+
+ test("JSON that isn't a table", () => {
+ refuses('{"hello":"world"}', "no seats in it");
+ refuses("[1,2,3]", "an array of nothing");
+ refuses("null", "null");
+ });
+
+ test("a save from something else that happens to be JSON", () => {
+ refuses('{"kind":"some.other.tool","table":{"seats":[]}}', "wrong kind");
+ });
+
+ test("a file from a newer version, rather than guessing at it", () => {
+ // Loading it risks reading a field whose meaning has changed, and the dealer
+ // would find out from the money.
+ const backup = JSON.parse(HoldemState.toBackup(aNight()));
+ backup.version = HoldemState.BACKUP_VERSION + 1;
+ const result = HoldemState.fromBackup(JSON.stringify(backup));
+ assert.match(result.error, /newer version/i);
+ });
+});
+
+describe("what it accepts anyway", () => {
+ test("a bare table, with no envelope around it", () => {
+ // Somebody who copied the storage value out by hand. The envelope is a
+ // convenience, not a password.
+ const state = aNight();
+ const result = HoldemState.fromBackup(JSON.stringify(state));
+ assert.ok(!result.error);
+ assert.deepEqual(result.table.seats.map(p => p.name), state.seats.map(p => p.name));
+ });
+
+ test("a table from before fields that exist now, migrated on the way in", () => {
+ // Same repairs a save from an older version gets, because it is the same
+ // function — a second, looser path for imports is how a format grows two
+ // meanings of the same field.
+ const state = aNight();
+ const raw = JSON.parse(JSON.stringify(state));
+ delete raw.deadButton;
+ delete raw.postedBlinds;
+ delete raw.dealtLastHand;
+ delete raw.discarded;
+ raw.seats.forEach(p => { delete p.buyIns; });
+
+ const { table, error } = HoldemState.fromBackup(JSON.stringify({
+ kind: HoldemState.BACKUP_KIND, version: 1, table: raw,
+ }));
+ assert.ok(!error);
+ assert.equal(table.deadButton, false);
+ assert.deepEqual(table.dealtLastHand, []);
+ assert.equal(table.discarded, 0);
+ assert.equal(table.seats[0].buyIns, 0);
+ });
+
+ test("the time it was saved comes back with it", () => {
+ const result = HoldemState.fromBackup(HoldemState.toBackup(aNight()));
+ assert.ok(result.savedAt, "so the dealer can tell two files apart");
+ assert.ok(!Number.isNaN(new Date(result.savedAt).getTime()));
+ });
+});
+
+describe("the file itself", () => {
+ test("is named with the date and time, so two saves in a night are told apart", () => {
+ const at = new Date(2026, 8, 2, 21, 5);
+ assert.equal(HoldemState.backupFileName(at), "holdem-table-2026-09-02-2105.json");
+ });
+
+ test("says what it is, for anyone finding it in a downloads folder", () => {
+ const parsed = JSON.parse(HoldemState.toBackup(aNight()));
+ assert.equal(parsed.kind, "casino.holdem.table");
+ assert.equal(parsed.version, 1);
+ assert.ok(parsed.table.seats.length > 0);
+ });
+});
From b97453fba1eddf4cf8bba954e62e665f18603142 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 03:13:00 -0600
Subject: [PATCH 41/55] Seat the table properly on a phone held sideways
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Landscape is the one shape this layout has never really fitted, and the audit
was calling two separate faults there.
The felt worked its own height out as `100vh - bar - 7rem`, guessing that
everything above it came to 7rem. That is 108px at 640x360, which the guess
covers, and 119px at 844x390, which it doesn't — so on an iPhone on its side the
bottom row of seats sat up to 26px inside the action bar. The measurement it
needed was already being published for the wide layout: the bar's real height,
the wrap's real offset and the bottom seat's overhang, all taken off the window.
It uses that now. The floor under it drops from 220px to 96px, because 220 was
not a size a 390px-tall window has to give — holding out for it was itself how
seats ended up behind the Fold button, which is the one thing the floor exists
to prevent.
That still left the header taking two rows, and the second row is 44px of the
390. The title block and the five buttons came to 829px inside a 765px header,
missing one line by 64px. The page title goes — the one thing on this screen a
dealer already knows — hidden from the eye rather than removed, so the heading
is still there for a screen reader. The buttons tighten across but never down:
they stay a 44px thumb target.
The seats themselves were spaced by angle, which is only even spacing on a
circle. On a 601x126 felt, ten seats at 36° apart put four of them down each
steep end, 20px apart, with pucks 72px tall — a seat's centre belonged to its
neighbour, so tapping a player got the wrong one. They now walk the rim by
distance instead, which puts them along the two long rails the way a real table
seats people. Closest neighbours go from 33px apart to 59px.
Measured at 640x360, 667x375 and 844x390 with 9-, 10- and 12-seat tables: every
combination the audit used to fail is clean, bar a 10-seat table at 640x360,
where two seats at the end of a rim that flat still overlap. Portrait, tablet
and desktop are unchanged — the floor never bound there, and on a rounder felt
walking the rim gives back the same angles it always did.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 60 ++++++-
.../wwwroot/js/casino/holdem/actionBar.js | 23 ++-
.../wwwroot/js/casino/holdem/render.js | 92 +++++++++--
tests/holdem/feltRoom.test.js | 73 +++++++++
tests/holdem/seatRing.test.js | 147 ++++++++++++++++++
5 files changed, 375 insertions(+), 20 deletions(-)
create mode 100644 tests/holdem/feltRoom.test.js
create mode 100644 tests/holdem/seatRing.test.js
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 6fc5ec8..c752e8f 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -3247,11 +3247,18 @@ body.hasActionBar #toastHost {
why a seat's centre belonged to its neighbour.
Landscape has width and no height, so the table takes the width it has and
- is only as tall as the room left over. */
- --feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 7rem);
+ is only as tall as the room left over.
+
+ The height comes from --feltRoomPx, the same measured value the wide layout
+ caps its width by: the bar's real height, the wrap's real offset and the
+ bottom seat's overhang, all taken off the window. This branch used to work
+ it out itself as `100vh - bar - 7rem`, guessing that everything above the
+ felt came to 7rem. It comes to 108px at 640x360, which the guess covers, and
+ 119px at 844x390, which it doesn't — so on an iPhone on its side the bottom
+ three seats sat up to 26px inside the action bar. Nothing here needs to
+ guess; the measurement was already being published. */
width: 100%;
- height: var(--feltRoom);
- min-height: 7rem;
+ height: var(--feltRoomPx, calc(100vh - var(--actionBarHeight, 0px) - 7rem));
/* This branch sets its own height outright, so neither the aspect ratio nor the
cap derived from it has anything to say here — and the cap would only narrow
a table that is already as short as the screen allows. */
@@ -3259,16 +3266,55 @@ body.hasActionBar #toastHost {
max-width: none;
}
+ /* The page's own top padding is 32px of nothing, on the screen that has the least
+ to spare. */
+ #holdemApp {
+ padding-top: 0.5rem;
+ }
+
/* The header ate 148px of a 390px-tall window, which was most of the reason the
- table started below the fold. Here it only needs to be a title bar. */
+ table started below the fold. Here it only needs to be a title bar.
+
+ It still wrapped to two rows, and that second row is 44px of the 390 — the title
+ block and the five buttons came to 829px inside a 765px header at 844x390, so
+ they missed sharing one line by 64px, and at 640x360 by rather more. What goes is
+ the page title: the one thing on this screen a dealer already knows. It is hidden
+ from the eye rather than removed, so the page keeps its heading for a screen
+ reader and for the browser's own outline. The hand number stays — nothing else on
+ the screen says which hand this is. */
#holdemHeader {
margin-bottom: 0.4rem;
gap: 0.35rem;
+ flex-wrap: nowrap;
+ align-items: center;
}
#holdemHeader h1 {
- font-size: 1.05rem;
- padding-bottom: 0.15rem;
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+
+ .handCounterBadge {
+ margin-top: 0;
+ }
+
+ /* Tightened across, never down: the buttons keep the 44px a thumb needs. */
+ #holdemHeaderActions {
+ flex-wrap: nowrap;
+ gap: 0.35rem;
+ }
+
+ #holdemHeaderActions .holdemBtn {
+ padding-left: 0.55rem;
+ padding-right: 0.55rem;
+ font-size: 0.78rem;
+ white-space: nowrap;
}
.holdemSubtitle {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 79070b5..0ec7dbd 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -53,7 +53,14 @@ const ActionBar = {
// moving the seats, and getting a different answer on the next pass.
SEAT_OVERHANG_PX: 37,
FELT_GAP_PX: 8,
- FELT_MIN_PX: 220,
+ // The floor is a guard against a measurement that has gone wrong — not a size the
+ // window has agreed to hand over. It used to be 220px, which a phone on its side
+ // hasn't got: at 844x390 there are 99px between the header and the bar, and holding
+ // out for 220 is how the bottom row of seats came to sit 26px inside the Fold
+ // button. A floor that puts seats behind the bar defeats the one thing the floor is
+ // for, so it drops to the smallest felt still worth drawing and the measurement
+ // wins above it.
+ FELT_FLOOR_PX: 96,
// How tall the felt can be and still leave the whole ring of seats above the bar.
//
@@ -66,14 +73,22 @@ const ActionBar = {
//
// The wrap's offset is taken in document space rather than viewport space, so
// scrolling the page doesn't resize the table under the dealer's hands.
+ feltRoomFor(windowHeight, barHeight, topInDocument) {
+ const room = windowHeight - barHeight - topInDocument -
+ this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
+ return Math.max(this.FELT_FLOOR_PX, Math.round(room));
+ },
+
publishFeltRoom(barHeight) {
const wrap = document.getElementById("pokerTableWrap");
if (!wrap) return;
+ // Taking the top before the height is set matters: the wrap sits below the
+ // header in normal flow, so its own height never moves its top and this can't
+ // chase itself around the screen.
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
- const room = window.innerHeight - barHeight - topInDocument -
- this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
document.documentElement.style.setProperty(
- "--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
+ "--feltRoomPx",
+ `${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 5a2755f..e64bc97 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -82,26 +82,100 @@ const Render = {
// ---- Seats around the table (compact pucks) ----
- seatPosition(index, count) {
- return this.positionOnRing(index, count, 44, 42);
+ seatPosition(index, count, aspect = this.feltAspect()) {
+ return this.positionOnRing(index, count, 44, 42, aspect);
+ },
+
+ // How many times wider than tall the felt actually is, which is the one thing the
+ // ring needs from the layout and can't work out from percentages. Falls back to a
+ // circle when there's nothing to measure — the same answer the ring gave before it
+ // asked.
+ feltAspect() {
+ if (typeof document === "undefined") return 1;
+ const wrap = document.getElementById("pokerTableWrap");
+ if (!wrap) return 1;
+ const box = wrap.getBoundingClientRect();
+ if (!box.width || !box.height) return 1;
+ return box.width / box.height;
},
// Bets sit on the felt between their player and the pot, the way chips actually
// do — closer in than the seats, on the same spoke. The bets layer is inset to
// match the seat ring (see the CSS), so this one radius holds at every size.
- betPosition(index, count) {
- return this.positionOnRing(index, count, 27, 25);
+ //
+ // The angle is the seat's own rather than one walked again round a tighter ellipse.
+ // The two answers are within half a degree of each other, so this is not a fix for
+ // anything visible — it is just the honest way to say "on that player's spoke",
+ // and it saves the second walk.
+ betPosition(index, count, aspect = this.feltAspect()) {
+ const angle = this.ringAngles(count, 44, 42, aspect)[index];
+ return { left: 50 + 27 * Math.cos(angle), top: 50 + 25 * Math.sin(angle) };
},
- positionOnRing(index, count, rx, ry) {
- const angle = -90 + (360 / count) * index;
- const radians = (angle * Math.PI) / 180;
+ // Seats spaced evenly around the rim — by the distance between them, not by the
+ // angle they sit at.
+ //
+ // Equal angles are only equal spacing on a circle. The felt is an ellipse, and a
+ // phone on its side makes it a very flat one: 601x126 at 640x360. Ten seats at 36°
+ // apart then put four of them down each steep side, where 36° of turn is barely any
+ // distance — seats 3 and 4 came out at the same x and 20px apart vertically, each
+ // puck 72px tall. Their centres landed on each other, so tapping a player got their
+ // neighbour, and the layout audit had been calling it at 640x360 and 667x375.
+ //
+ // Walking the perimeter instead puts the seats where a real table has them: along
+ // the two long rails, one at each end. The arc length is summed numerically because
+ // an ellipse's has no closed form — 720 steps is far more than enough for a rim a
+ // few hundred pixels long, and it runs once per render.
+ positionOnRing(index, count, rx, ry, aspect = 1) {
+ const target = this.ringAngles(count, rx, ry, aspect)[index];
return {
- left: 50 + rx * Math.cos(radians),
- top: 50 + ry * Math.sin(radians),
+ left: 50 + rx * Math.cos(target),
+ top: 50 + ry * Math.sin(target),
};
},
+ RING_STEPS: 720,
+ ringCache: {},
+
+ // The angles that cut the rim into `count` equal lengths, starting at the top.
+ // Every seat on a ring wants the same answer, so it is worked out once per shape
+ // and kept — the ring only changes when the table is resized or the window is.
+ ringAngles(count, rx, ry, aspect) {
+ if (count <= 0) return [];
+ const key = `${count}:${rx}:${ry}:${aspect.toFixed(3)}`;
+ if (this.ringCache[key]) return this.ringCache[key];
+
+ const start = -Math.PI / 2;
+ // Percentages of the width and of the height are not the same distance, so the
+ // horizontal radius is scaled into the vertical one's units before measuring.
+ const a = rx * aspect;
+ const b = ry;
+ const step = (2 * Math.PI) / this.RING_STEPS;
+ const cumulative = [0];
+ for (let i = 1; i <= this.RING_STEPS; i++) {
+ const from = start + (i - 1) * step;
+ const to = start + i * step;
+ const dx = a * (Math.cos(to) - Math.cos(from));
+ const dy = b * (Math.sin(to) - Math.sin(from));
+ cumulative.push(cumulative[i - 1] + Math.hypot(dx, dy));
+ }
+
+ const perimeter = cumulative[this.RING_STEPS];
+ const angles = [];
+ let walked = 0;
+ for (let seat = 0; seat < count; seat++) {
+ const wanted = (perimeter * seat) / count;
+ while (walked < this.RING_STEPS && cumulative[walked + 1] < wanted) walked++;
+ // Interpolate inside the step the seat falls in, so the answer doesn't
+ // jump in 0.5° increments.
+ const spanned = cumulative[walked + 1] - cumulative[walked];
+ const into = spanned > 0 ? (wanted - cumulative[walked]) / spanned : 0;
+ angles.push(start + (walked + into) * step);
+ }
+ this.ringCache[key] = angles;
+ return angles;
+ },
+
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
// gets bets that look like the chips actually being pushed out.
chipColorFor(amount) {
diff --git a/tests/holdem/feltRoom.test.js b/tests/holdem/feltRoom.test.js
new file mode 100644
index 0000000..6c21c0b
--- /dev/null
+++ b/tests/holdem/feltRoom.test.js
@@ -0,0 +1,73 @@
+// How tall the felt is allowed to be.
+//
+// The bar is fixed to the bottom of the screen and grows with the hand, so the table
+// has to be told what is left. Everything below is the arithmetic that decides it —
+// the part that can be wrong without anyone noticing until a seat is behind the Fold
+// button.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { ActionBar } = require("./load");
+
+// window height, bar height, and how far down the page the felt starts.
+const room = (windowHeight, barHeight, top) => ActionBar.feltRoomFor(windowHeight, barHeight, top);
+
+describe("the room left for the felt", () => {
+ test("is what the window has after the bar, the header and the bottom seat", () => {
+ const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
+ assert.equal(room(900, 127, 146), 900 - 127 - 146 - reserved);
+ });
+
+ test("shrinks when the bar grows mid-hand", () => {
+ // Side pots make the bar taller exactly when the dealer most needs to see who
+ // is still in.
+ const quiet = room(900, 117, 146);
+ const sidePots = room(900, 172, 146);
+ assert.equal(quiet - sidePots, 55);
+ });
+
+ test("leaves the bottom seat's overhang out of it", () => {
+ // The seat pucks hang below the felt's edge; the felt has to stop short by that
+ // much or the ring ends up in the bar even though the felt doesn't.
+ assert.ok(ActionBar.SEAT_OVERHANG_PX > 30, "half a puck");
+ assert.ok(room(900, 127, 146) < 900 - 127 - 146);
+ });
+});
+
+describe("a window too short to give what was asked", () => {
+ // A phone on its side: 390px tall, 127px of that is the bar and 119px is
+ // everything above the felt. What's left is under 100.
+ const landscape = () => room(390, 127, 119);
+
+ test("gets the measurement, not a comfortable number", () => {
+ // The floor used to be 220px, which this window hasn't got. Holding out for it
+ // put the bottom row of seats 26px inside the action bar — the exact fault the
+ // measurement exists to prevent.
+ assert.ok(landscape() < 150, `${landscape()}px`);
+ assert.ok(landscape() + 119 + 127 <= 390, "the felt still ends above the bar");
+ });
+
+ test("still draws a table rather than a line", () => {
+ assert.equal(ActionBar.feltRoomFor(200, 160, 100), ActionBar.FELT_FLOOR_PX);
+ assert.equal(ActionBar.feltRoomFor(0, 0, 0), ActionBar.FELT_FLOOR_PX);
+ });
+
+ test("never returns something a browser can't lay out", () => {
+ [[390, 300, 200], [100, 90, 90], [-10, 0, 0]].forEach(([h, bar, top]) => {
+ const answer = ActionBar.feltRoomFor(h, bar, top);
+ assert.ok(Number.isFinite(answer) && answer > 0, `${h}/${bar}/${top} gave ${answer}`);
+ });
+ });
+});
+
+describe("a window with room to spare", () => {
+ test("hands over everything that's left, floor or no floor", () => {
+ const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
+ assert.equal(room(1000, 127, 146), 1000 - 127 - 146 - reserved);
+ assert.equal(room(1366, 120, 206), 1366 - 120 - 206 - reserved);
+ });
+
+ test("gives a whole number of pixels", () => {
+ assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
+ });
+});
diff --git a/tests/holdem/seatRing.test.js b/tests/holdem/seatRing.test.js
new file mode 100644
index 0000000..7ed6901
--- /dev/null
+++ b/tests/holdem/seatRing.test.js
@@ -0,0 +1,147 @@
+// Where the seats sit on the rim.
+//
+// The ring used to space seats by angle, which is only the same as spacing them evenly
+// when the felt is a circle. On a phone held sideways the felt is 601x126, and equal
+// angles put four seats down each steep end, 20px apart, with pucks 72px tall — so a
+// seat's centre belonged to its neighbour and tapping a player got the wrong one.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Render } = require("./load");
+
+// The seats as a dealer sees them: pixels on a felt of a given size.
+const ringPixels = (count, width, height, rx = 44, ry = 42) => {
+ const aspect = width / height;
+ return Array.from({ length: count }, (_, i) => {
+ const pos = Render.positionOnRing(i, count, rx, ry, aspect);
+ return { x: (pos.left / 100) * width, y: (pos.top / 100) * height };
+ });
+};
+
+const gaps = (points) => points.map((p, i) => {
+ const next = points[(i + 1) % points.length];
+ return Math.hypot(next.x - p.x, next.y - p.y);
+});
+
+// What the ring did before: one turn cut into equal angles.
+const byAngle = (index, count, rx, ry) => {
+ const radians = ((-90 + (360 / count) * index) * Math.PI) / 180;
+ return { left: 50 + rx * Math.cos(radians), top: 50 + ry * Math.sin(radians) };
+};
+
+describe("a round table", () => {
+ test("still spaces seats by angle, because on a circle that is the same thing", () => {
+ for (let seat = 0; seat < 9; seat++) {
+ const now = Render.positionOnRing(seat, 9, 40, 40, 1);
+ const before = byAngle(seat, 9, 40, 40);
+ assert.ok(Math.abs(now.left - before.left) < 0.2, `seat ${seat} left`);
+ assert.ok(Math.abs(now.top - before.top) < 0.2, `seat ${seat} top`);
+ }
+ });
+
+ test("seat one is at the top, whatever shape the felt is", () => {
+ [[600, 600], [601, 126], [351, 351], [949, 683]].forEach(([w, h]) => {
+ const first = Render.positionOnRing(0, 10, 44, 42, w / h);
+ assert.ok(Math.abs(first.left - 50) < 0.01, `${w}x${h} left`);
+ assert.ok(first.top < 50, `${w}x${h} is above the middle`);
+ });
+ });
+});
+
+describe("a felt wider than it is tall", () => {
+ // 640x360 landscape: the size the audit was failing at.
+ const wide = () => ringPixels(10, 601, 126);
+
+ const byAnglePixels = () => Array.from({ length: 10 }, (_, i) => {
+ const pos = byAngle(i, 10, 44, 42);
+ return { x: (pos.left / 100) * 601, y: (pos.top / 100) * 126 };
+ });
+
+ // The two seats at the right-hand end, which is where they crowd.
+ const endPairApart = (seats) => {
+ const rightmost = [...seats].sort((a, b) => b.x - a.x).slice(0, 2);
+ return Math.abs(rightmost[0].y - rightmost[1].y);
+ };
+
+ test("pulls the pair at each end apart", () => {
+ // They still share the end — ten seats on a rim this flat have nowhere else to
+ // be — but they stop sitting on top of each other.
+ const now = endPairApart(wide());
+ const before = endPairApart(byAnglePixels());
+ assert.ok(now > before * 1.4, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
+ });
+
+ test("nearly doubles the room between the closest two neighbours", () => {
+ // Not equal spacing in a straight line — at the ends the rim turns back on
+ // itself, so the same walk along it covers less ground across. Even there the
+ // gap goes from a third of a puck to most of one.
+ const now = Math.min(...gaps(wide()));
+ const before = Math.min(...gaps(byAnglePixels()));
+ assert.ok(now > before * 1.7, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
+ assert.ok(now > 55, `${now.toFixed(1)}px`);
+ });
+
+ test("never sits two seats on the same spot", () => {
+ const seats = wide();
+ seats.forEach((seat, i) => {
+ seats.slice(i + 1).forEach((other, j) => {
+ const apart = Math.hypot(seat.x - other.x, seat.y - other.y);
+ assert.ok(apart > 50, `seats ${i + 1} and ${i + j + 2} are ${apart.toFixed(1)}px apart`);
+ });
+ });
+ });
+});
+
+describe("the shape of the ring", () => {
+ test("is a mirror image left to right", () => {
+ const seats = ringPixels(10, 601, 126);
+ for (let seat = 1; seat < 5; seat++) {
+ const mirrored = seats[10 - seat];
+ assert.ok(Math.abs((seats[seat].x - 300.5) + (mirrored.x - 300.5)) < 0.5, `seat ${seat} across`);
+ assert.ok(Math.abs(seats[seat].y - mirrored.y) < 0.5, `seat ${seat} down`);
+ }
+ });
+
+ test("holds for the smallest and largest tables the seat count allows", () => {
+ [2, 3, 4, 9, 12, 24].forEach(count => {
+ const seats = ringPixels(count, 601, 126);
+ assert.equal(seats.length, count);
+ seats.forEach((seat, i) => {
+ assert.ok(Number.isFinite(seat.x) && Number.isFinite(seat.y), `${count}-handed seat ${i + 1}`);
+ assert.ok(seat.x >= 0 && seat.x <= 601, `${count}-handed seat ${i + 1} is on the felt`);
+ });
+ assert.ok(Math.min(...gaps(seats)) > 0, `${count}-handed seats are distinct`);
+ });
+ });
+
+ test("shares the rim out equally on a round table at any size", () => {
+ [3, 6, 9, 10, 24].forEach(count => {
+ const spacing = gaps(ringPixels(count, 500, 500, 40, 40));
+ const shortest = Math.min(...spacing);
+ const longest = Math.max(...spacing);
+ assert.ok(longest - shortest < 0.5,
+ `${count}-handed gaps ran ${shortest.toFixed(1)}-${longest.toFixed(1)}px`);
+ });
+ });
+
+ test("asked for no seats, offers no positions", () => {
+ assert.deepEqual(Render.ringAngles(0, 44, 42, 1), []);
+ });
+
+ test("puts each bet between its own player and the pot", () => {
+ // Where a dealer pushes the chips: on that player's line to the middle, short
+ // of the seat.
+ const bearing = (point) => Math.atan2(point.top - 50, point.left - 50);
+ const outFromMiddle = (point) => Math.hypot(point.left - 50, point.top - 50);
+
+ [[601, 126], [810, 583], [351, 351]].forEach(([w, h]) => {
+ for (let seat = 0; seat < 9; seat++) {
+ const player = Render.seatPosition(seat, 9, w / h);
+ const bet = Render.betPosition(seat, 9, w / h);
+ const offBy = Math.abs(bearing(bet) - bearing(player)) * (180 / Math.PI);
+ assert.ok(offBy < 1, `${w}x${h} seat ${seat + 1} is ${offBy.toFixed(1)}° off its spoke`);
+ assert.ok(outFromMiddle(bet) < outFromMiddle(player), `${w}x${h} seat ${seat + 1} is short of the seat`);
+ }
+ });
+ });
+});
From fc866597eb3e9d0f8988754af550f07856d11a4b Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 03:32:17 -0600
Subject: [PATCH 42/55] Deal the straddle, instead of leaving the dealer to
fake it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A straddle is a blind raise put out before the cards by the player left of the
big blind, for twice it. Home games run them constantly, and the tool had no
idea they existed — so a dealer's only way through was to deal the hand and have
the straddler "raise" to $4, which gets the money right and the other two rules
wrong.
There are three at once:
- the amount doubles as it goes left, and re-straddles carry on doubling;
- first to act is left of the *straddler*, not left of the big blind, and the
straddler keeps the option when it comes back round even if nobody raised;
- the minimum raise is twice the straddle. Measured off the big blind
underneath it, a $2/$4 game prices the next raise at $6, and every player at
the table will argue — correctly.
Tap Straddle on the deal prompt and the tool posts it, puts the right player on
the clock and prices the raise. The seat wears an STR badge for the hand, read
off what the deal actually posted rather than off the count, so the badge can't
outlive the chips it stands for. It's a one-hand decision, like posting in, and
it clears when the button moves.
The control sits on the deal row rather than a line of its own: measured at
390x844, its own line cost 44px of felt on every deal prompt, straddle or no
straddle, and this bar is docked over the seats. Three buttons across a 360px
phone want to wrap, which made the bar 64px taller and put the bottom seat three
fifths behind it — this layout deliberately doesn't shrink the felt to suit, so
the deal button is given a 7rem floor instead, which keeps the row to one line
and the cost to nothing.
20 new tests, checked by breaking each of the three rules on purpose first: the
wrong first-to-act, the minimum measured off the big blind, and a flat rather
than doubling chain each fail a different one.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 18 ++
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 45 +++++
.../wwwroot/js/casino/holdem/actionBar.js | 53 ++++-
.../wwwroot/js/casino/holdem/betting.js | 80 +++++++-
.../wwwroot/js/casino/holdem/render.js | 8 +
.../wwwroot/js/casino/holdem/state.js | 17 ++
tests/holdem/straddle.test.js | 188 ++++++++++++++++++
7 files changed, 400 insertions(+), 9 deletions(-)
create mode 100644 tests/holdem/straddle.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index a984cab..d2bb9d2 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -208,6 +208,7 @@
DSBBB
+ STR
@@ -360,6 +361,23 @@
+
+
A straddle is a blind raise
+
+ The player left of the big blind may put out twice it before the cards,
+ without looking. It is a blind bet, not a raise made in turn, so it does
+ three things at once: everyone else acts first, the minimum raise becomes
+ twice the straddle, and the straddler still has the option when the
+ action comes back — they may raise even if nobody raised into them.
+
+
+ Re-straddles carry on to the left, each doubling again, and stop before
+ the small blind. Tap Straddle on the deal prompt and the tool
+ posts it, puts the right player on the clock and prices the raise;
+ the seat wears an STR badge for the hand.
+
+
+
A short all-in doesn't reopen the betting
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index c752e8f..4903923 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -796,6 +796,19 @@ button,
color: #241a05;
}
+/* The straddle. It takes the big blind's slot on the puck, which is free by
+ construction: the chain starts left of the big blind and stops short of the small
+ one, so no seat ever wears two of these.
+
+ Violet rather than a third shade of gold or blue, because the badge a dealer is
+ looking for mid-hand is the one telling them where the action comes back to.
+ Dark text on the light end of the ramp, measured 5.76:1 against the darker stop. */
+.strBadge {
+ left: 22px;
+ background: linear-gradient(180deg, #cbaaf0, #a97fd6);
+ color: #1b1030;
+}
+
/* A blind the dealer named by hand rather than one the button handed out. Without
the ring the table just looks like it's put the blinds in the wrong place. */
.blindBadge.blindManual {
@@ -3520,6 +3533,38 @@ textarea:focus-visible {
font-weight: 600;
}
+/* The straddle rides on the deal row, so it must not take half of it: .actionBtn is
+ flex: 1 and would have split the row evenly with the button the dealer presses every
+ hand. It sizes to its own words instead, and stays quiet next to the green one —
+ choosing a straddle is the exception, dealing is the rule. */
+.actionBtn.actionStraddle {
+ flex: 0 1 auto;
+ background: linear-gradient(180deg, #33304a, #24223a);
+ border-color: #4b4770;
+ color: #e6e1ff;
+ font-size: 0.82rem;
+ padding-left: 0.7rem;
+ padding-right: 0.7rem;
+}
+
+.actionBtn.actionStraddleOff {
+ background: linear-gradient(180deg, #2e2e2e, #232323);
+ border-color: #4a4a4a;
+ color: #dcdcdc;
+}
+
+/* Three buttons across a 360px phone is a squeeze, and the two ways out are both
+ worse than this one. Letting the row wrap put the deal button on its own line and
+ made the bar 64px taller — measured at 360x640, that is the bottom seat three fifths
+ behind the bar, and this layout deliberately doesn't shrink the felt to suit (see the
+ max-width: none override, where capping it was tried and rejected). Letting flex
+ squash freely took the deal button to 84px with its label over three lines.
+ A floor of 7rem keeps the label to two lines and the row to one, so arming a straddle
+ costs no felt at all. */
+.dealPromptButtons .actionDeal {
+ min-width: 7rem;
+}
+
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
would otherwise only discover afterwards. */
.dealPromptLine.dealPromptAside {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 0ec7dbd..5455772 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -592,6 +592,43 @@ const ActionBar = {
// by itself the instant the previous hand ended, which meant blinds were committed
// while people were still sitting down — and the blind badges would drift away from
// the money already posted as seats changed. Now the dealer says when.
+ // ", Dee straddles $4" — read out as part of what goes in before the cards, because
+ // that is what it is. Two of them read as "Dee straddles $4, Eve $8": the second
+ // name doesn't need the verb again, and the bar has no room for it.
+ straddleClause(preview) {
+ const straddles = preview.straddles || [];
+ if (straddles.length === 0) return "";
+ const name = i => HoldemState.displayName(this.state.seats[i], i);
+ const [first, ...rest] = straddles;
+ return `, ${name(first.index)} straddles ${Money.format(first.amount)}` +
+ rest.map(s => `, ${name(s.index)} ${Money.format(s.amount)}`).join("");
+ },
+
+ // Turning it on, doubling it, and calling it off. Heads-up there is no seat left of
+ // the big blind to straddle from, so nothing is offered rather than a button that
+ // would do nothing.
+ straddleButtons(preview) {
+ const on = (preview.straddles || []).length;
+ const max = preview.maxStraddles || 0;
+ if (max <= 0) return [];
+
+ const buttons = [];
+ const nextAmount = Money.format(this.state.bigBlind * Math.pow(2, on + 1));
+ if (on < max) {
+ buttons.push(this.button(
+ on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
+ "actionStraddle",
+ () => this.commit(on === 0 ? "the straddle" : "the re-straddle",
+ () => { this.state.straddleCount = on + 1; })));
+ }
+ if (on > 0) {
+ buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
+ () => this.commit("calling the straddle off",
+ () => { this.state.straddleCount = 0; })));
+ }
+ return buttons;
+ },
+
renderDealPrompt() {
const wrap = document.createElement("div");
wrap.className = "dealPrompt";
@@ -689,11 +726,14 @@ const ActionBar = {
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
// the small blind" out loud is the whole point — a dealer who reads a preview
// with a blind missing and isn't told why will assume the tool has lost track.
- line.textContent = preview.deadSmallBlind
+ const blinds = document.createElement("span");
+ blinds.textContent = (preview.deadSmallBlind
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
- `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
- `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
+ `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`) +
+ this.straddleClause(preview) +
+ `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
+ line.appendChild(blinds);
wrap.appendChild(line);
// Why a position is missing. Both of these are the dead button rule doing its
@@ -767,7 +807,12 @@ const ActionBar = {
}
const row = document.createElement("div");
- row.className = "actionButtons";
+ row.className = "actionButtons dealPromptButtons";
+ // Beside the deal button rather than on a line of its own. Measured at 390x844:
+ // its own line cost 44px of felt on every deal prompt, straddle or no straddle,
+ // and this row is already there and already 44px tall. It reads in the order it
+ // happens, too — say the straddle, then deal.
+ this.straddleButtons(preview).forEach(button => row.appendChild(button));
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index baa240e..07f9b21 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -93,9 +93,60 @@ const Betting = {
satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
+ // What the straddles will be if the hand is dealt as it stands, and how
+ // many more this table could take.
+ straddles: this.straddleSeats(state),
+ maxStraddles: this.maxStraddles(state),
};
},
+ // ---- Straddles ----
+ //
+ // A straddle is a blind raise put out before the cards, by the player left of the
+ // big blind, for twice it. It buys the last word before the flop: everyone else
+ // acts first, and the straddler still has the option when it comes back round even
+ // if nobody raised. Re-straddles carry on left, each doubling again.
+ //
+ // Home games run them constantly and a dealer has to get three things right at
+ // once — the amount, who is first to act, and what the minimum raise becomes. The
+ // tool used to do none of them, so a dealer's only way through was to deal, have
+ // the straddler "raise" to $4, and hand the action to the wrong player.
+
+ // How many straddles this table could take. Everyone but the two blinds can put
+ // one out; heads-up that is nobody, which is the right answer — the button is
+ // already the small blind and there is no seat left of the big blind to straddle
+ // from.
+ maxStraddles(state) {
+ return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
+ },
+
+ // Who straddles and for how much, walking left from the big blind and doubling.
+ // Asked of the table as it will be dealt, so a seat about to be sat out for having
+ // no chips is never handed a straddle it can't post.
+ straddleSeats(state, count = state.straddleCount) {
+ const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
+ if (wanted <= 0) return [];
+
+ const dealt = this.tableAsDealt(state);
+ const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
+ const asDealt = buttonIndex === dealt.dealerSeatIndex
+ ? dealt
+ : { ...dealt, dealerSeatIndex: buttonIndex };
+ const { bbIndex } = HoldemState.getBlindSeats(asDealt);
+ if (bbIndex === null) return [];
+
+ const straddles = [];
+ let index = bbIndex;
+ let amount = this.round((Number(state.bigBlind) || 0) * 2);
+ for (let n = 0; n < wanted; n++) {
+ index = this.nextToAct(asDealt, index);
+ if (index === null || index === bbIndex) break;
+ straddles.push({ index, amount });
+ amount = this.round(amount * 2);
+ }
+ return straddles;
+ },
+
// Does handing this seat chips also put them back in the game?
//
// At a real table those are one event, and here they were two: a busted player got
@@ -475,6 +526,7 @@ const Betting = {
state.currentBet = 0;
state.actingSeatIndex = null;
state.postedBlinds = null;
+ state.postedStraddles = [];
return;
}
@@ -505,17 +557,35 @@ const Betting = {
// from one who has just walked up and had the blinds go past them.
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
- state.currentBet = this.round(Math.max(
- sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
- this.committed(state.seats[bbIndex])));
- state.lastRaiseSize = bb || 0;
+ // Straddles go out after the blinds and before the cards, each one left of the
+ // last. Posted here rather than left to the dealer to fake as a raise, because
+ // a raise from that seat would hand the action to the wrong player.
+ const straddles = this.straddleSeats(state);
+ straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
+ state.postedStraddles = straddles.map(({ index }) => index);
+
+ state.currentBet = this.round(state.seats.reduce(
+ (most, player) => Math.max(most, this.committed(player)), 0));
+ // A straddle is a blind raise, so what has to be matched to raise again is the
+ // straddle itself, not the big blind underneath it: $2 blinds with a $4
+ // straddle make the next raise $8, the same way the $2 blind makes it $4.
+ // Left alone, the minimum would come out at $6 and the dealer would be talked
+ // out of it by every player at the table.
+ state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
// Heads-up the button is the small blind and acts first before the flop; with
// three or more it's the seat after the big blind. Either way, a blind big
// enough to put that player all-in leaves them nothing to decide.
+ //
+ // A straddle moves that seat along: first to act is left of the last straddler,
+ // and the straddler keeps the option, which falls out of nobody having acted
+ // yet.
+ const lastPosted = straddles.length > 0
+ ? straddles[straddles.length - 1].index
+ : bbIndex;
state.actingSeatIndex = headsUp
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
- : this.nextToAct(state, bbIndex);
+ : this.nextToAct(state, lastPosted);
},
// What a pot-sized raise actually is, as a "raise to" total.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index e64bc97..3ec0850 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -310,6 +310,14 @@ const Render = {
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
+ // A straddle is a blind too, and the seat wearing it is the one the action
+ // comes back to. Marked from what the deal actually posted rather than from
+ // the count, so the badge can't outlive the chips it stands for — before
+ // the cards there is nothing out there yet.
+ const straddleBadge = node.querySelector(".strBadge");
+ const straddled = (this.state.postedStraddles || []).includes(index);
+ straddleBadge.classList.toggle("visible", this.state.bettingOpen && straddled);
+
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index fdc7e4a..d3404ae 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -100,6 +100,14 @@ const HoldemState = {
// the big blind rather than waiting for it. Cleared every hand: it's a
// decision about one deal, not a standing arrangement.
postingIn: [],
+ // How many straddles are on for the hand about to be dealt. A straddle is
+ // announced hand by hand, so this is cleared with every other one-deal
+ // decision rather than standing until somebody turns it off.
+ straddleCount: 0,
+ // Which seats actually put a straddle out when the hand was dealt, so the
+ // felt can badge them. Read off the deal rather than worked out again
+ // later, the same way the posted blinds are.
+ postedStraddles: [],
// Set when somebody leaves out of the blinds and the rotation has to hold
// to the seats to keep the big blind moving one player at a time. The
// button then sits on the chair they vacated, and no small blind is posted.
@@ -201,6 +209,11 @@ const HoldemState = {
// asking the whole table to post.
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
+ // None on is the right reading of a save from before straddles existed:
+ // whatever is in front of those players is already in their committed
+ // chips, and nothing is owed on top of it.
+ if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
+ if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
@@ -643,6 +656,10 @@ const HoldemState = {
// Posting to come in is a decision about one deal. Carrying it forward would
// charge somebody an extra big blind every hand for the rest of the night.
state.postingIn = [];
+ // So is a straddle. It is announced hand by hand at a table, and a straddle
+ // that stayed on would take chips off somebody who never said the word.
+ state.straddleCount = 0;
+ state.postedStraddles = [];
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
diff --git a/tests/holdem/straddle.test.js b/tests/holdem/straddle.test.js
new file mode 100644
index 0000000..e50693f
--- /dev/null
+++ b/tests/holdem/straddle.test.js
@@ -0,0 +1,188 @@
+// The straddle: a blind raise put out before the cards by the player left of the big
+// blind, for twice it.
+//
+// Home games run them constantly, and it is three rules at once — the amount, who is
+// first to act, and what the minimum raise becomes. Faking one by dealing and having
+// the straddler "raise" gets the last of those right and the other two wrong.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
+
+// Six seats, everyone in, button on seat 0: small blind seat 1, big blind seat 2,
+// straddle seat 3.
+const table = (straddles = 0, extra = {}) => {
+ const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0, ...extra });
+ state.straddleCount = straddles;
+ return state;
+};
+
+const dealt = (straddles = 0, extra = {}) => {
+ const state = table(straddles, extra);
+ Betting.beginHand(state);
+ return state;
+};
+
+describe("what goes in the middle", () => {
+ test("the seat left of the big blind puts out twice it", () => {
+ const state = dealt(1);
+ assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
+ });
+
+ test("a re-straddle doubles again, one seat further round", () => {
+ const state = dealt(2);
+ assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 8, 0]);
+ });
+
+ test("none of it comes from anywhere but the straddler's stack", () => {
+ const before = chipsOnTable(table(2));
+ assert.equal(chipsOnTable(dealt(2)), before);
+ });
+
+ test("the felt is told who straddled, not left to work it out", () => {
+ assert.deepEqual(dealt(2).postedStraddles, [3, 4]);
+ assert.deepEqual(dealt(0).postedStraddles, []);
+ });
+});
+
+describe("who is on the clock", () => {
+ test("action starts left of the straddler, not left of the big blind", () => {
+ assert.equal(dealt(0).actingSeatIndex, 3, "without a straddle it is the seat after the big blind");
+ assert.equal(dealt(1).actingSeatIndex, 4);
+ assert.equal(dealt(2).actingSeatIndex, 5);
+ });
+
+ test("the straddler still has the option when it comes back to them", () => {
+ const state = dealt(1);
+ // Everyone calls the straddle round to the big blind.
+ [4, 5, 0, 1, 2].forEach(seat => {
+ assert.equal(state.actingSeatIndex, seat, `expected seat ${seat} to be up`);
+ Betting.call(state, seat);
+ });
+ assert.equal(state.actingSeatIndex, 3, "back to the straddler");
+ assert.equal(Betting.toCall(state, state.seats[3]), 0, "nothing to call");
+ assert.equal(Betting.isStreetComplete(state), false, "and the street isn't over until they say so");
+ });
+
+ test("the straddler checking closes it", () => {
+ const state = dealt(1);
+ [4, 5, 0, 1, 2].forEach(seat => Betting.call(state, seat));
+ Betting.check(state, 3);
+ assert.equal(Betting.isStreetComplete(state), true);
+ assert.equal(state.actingSeatIndex, null);
+ });
+
+ test("the big blind acts in turn, not last", () => {
+ const state = dealt(1);
+ Betting.call(state, 4);
+ Betting.call(state, 5);
+ Betting.call(state, 0);
+ Betting.call(state, 1);
+ assert.equal(state.actingSeatIndex, 2, "the big blind is up before the straddler");
+ assert.equal(Betting.toCall(state, state.seats[2]), 2, "and owes the difference to the straddle");
+ });
+});
+
+describe("what it costs to raise", () => {
+ test("the minimum raise is twice the straddle, not the straddle plus a blind", () => {
+ // $2 blinds, $4 straddle: the next raise is to $8. Measured off the big blind
+ // instead it comes out at $6, and every player at the table would argue.
+ assert.equal(Betting.minRaiseTo(dealt(1)), 8);
+ assert.equal(Betting.minRaiseTo(dealt(2)), 16);
+ assert.equal(Betting.minRaiseTo(dealt(0)), 4);
+ });
+
+ test("what the table owes is measured off the straddle", () => {
+ const state = dealt(1);
+ assert.equal(state.currentBet, 4);
+ assert.equal(Betting.toCall(state, state.seats[4]), 4, "a fresh player calls the straddle");
+ assert.equal(Betting.toCall(state, state.seats[1]), 3, "the small blind owes the rest");
+ assert.equal(Betting.toCall(state, state.seats[2]), 2, "so does the big blind");
+ assert.equal(Betting.toCall(state, state.seats[3]), 0, "the straddler is already good");
+ });
+
+ test("a raise over the straddle is a raise, and the straddler can still re-raise", () => {
+ const state = dealt(1);
+ Betting.raiseTo(state, 4, 12);
+ assert.equal(state.currentBet, 12);
+ assert.equal(Betting.minRaiseTo(state), 20, "twice again over a raise of eight");
+ assert.equal(state.seats[3].hasActed, false, "the straddler has not acted yet");
+ });
+});
+
+describe("how many a table can take", () => {
+ test("everyone but the two blinds", () => {
+ assert.equal(Betting.maxStraddles(table(0)), 4);
+ assert.equal(Betting.maxStraddles(table(0, { seatCount: 3, seated: [0, 1, 2] })), 1);
+ });
+
+ test("heads-up, none — there is no seat left of the big blind to straddle from", () => {
+ const heads = table(1, { seatCount: 6, seated: [0, 1] });
+ assert.equal(Betting.maxStraddles(heads), 0);
+ assert.deepEqual(Betting.straddleSeats(heads), []);
+ Betting.beginHand(heads);
+ assert.deepEqual(heads.seats.map(p => p.committed), [1, 2, 0, 0, 0, 0]);
+ });
+
+ test("asking for more than the table has stops at the last seat", () => {
+ const state = table(0, { seatCount: 4, seated: [0, 1, 2, 3] });
+ state.straddleCount = 99;
+ assert.equal(Betting.straddleSeats(state).length, 2);
+ Betting.beginHand(state);
+ assert.deepEqual(state.seats.map(p => p.committed), [8, 1, 2, 4]);
+ assert.equal(state.actingSeatIndex, 1, "round to the small blind, who is first to act");
+ });
+
+ test("a seat with no chips is never handed one", () => {
+ const state = table(1);
+ state.seats[3].stack = 0;
+ // Seat 3 is sat out for the deal, so the straddle passes to seat 4.
+ assert.deepEqual(Betting.straddleSeats(state), [{ index: 4, amount: 4 }]);
+ });
+});
+
+describe("what it doesn't outlive", () => {
+ test("the hand it was called for", () => {
+ const state = dealt(1);
+ HoldemState.startNextHand(state);
+ assert.equal(state.straddleCount, 0, "nobody straddles again by accident");
+ assert.deepEqual(state.postedStraddles, []);
+ });
+
+ test("but it survives the button being pushed around before the deal", () => {
+ // repostBlinds takes every posted chip back and deals again; a straddle that
+ // vanished there would have been paid once and never returned.
+ const state = dealt(1);
+ const before = chipsOnTable(state);
+ Betting.repostBlinds(state);
+ assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
+ assert.equal(chipsOnTable(state), before);
+ });
+
+ test("a table saved before straddles existed reads as having none", () => {
+ const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
+ delete parsed.straddleCount;
+ delete parsed.postedStraddles;
+ const migrated = HoldemState.migrate(parsed);
+ assert.equal(migrated.straddleCount, 0);
+ assert.deepEqual(migrated.postedStraddles, []);
+ });
+});
+
+describe("what the dealer is shown before dealing", () => {
+ test("the straddles that are coming, and how many more it could take", () => {
+ const preview = Betting.blindPreview(table(1));
+ assert.deepEqual(preview.straddles, [{ index: 3, amount: 4 }]);
+ assert.equal(preview.maxStraddles, 4);
+ });
+
+ test("and the preview matches what dealing actually does", () => {
+ const state = table(2);
+ const preview = Betting.blindPreview(state);
+ Betting.beginHand(state);
+ assert.deepEqual(preview.straddles.map(s => s.index), state.postedStraddles);
+ preview.straddles.forEach(({ index, amount }) => {
+ assert.equal(state.seats[index].committed, amount, `seat ${index + 1}`);
+ });
+ });
+});
From b9b2237dbe6d0dcf90d1266df0bb3c473a4a1dc2 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 04:02:43 -0600
Subject: [PATCH 43/55] Run the two checks the audit had and never used
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
runAll() did not include the contrast check — it simply wasn't in the list — and
touch targets sat behind an opt-in flag nothing passed. So "the audit is clean"
has meant nine checks rather than eleven for a long run of rounds, and the two
left out are the two whose faults a dealer meets in a dim room with their thumb.
Both were written, tested, and then never called; the contrast checker was built
across two rounds and every sweep since has been silent about colour.
The standalone runner had a second version of the same hole: the 44px rule
stands itself down where `hover: hover` says there's a pointer, and a headless
desktop browser is a pointer, so a single pass had never measured a touch target
at any viewport. It now runs every viewport twice, once with a mouse and once
with a thumb.
A check that decides it doesn't apply now says so, rather than reporting an empty
offenders list — which reads exactly like a pass.
Faded text is measured instead of filed as unchecked. `opacity` on a box fades
the box and its text together onto whatever is behind it, so a dimmed seat is not
grey text on a dark puck: it is a faded puck and faded text both landing on the
page. Both halves are composited that way now. Two exemptions remain and both say
so out loud: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.
What running them found, and this round fixes:
- the chair number on every seat: #8a8a8a at 9px, 3.39:1 once the empty seat's
fade is composited, on the label that answers "which seat is that?";
- Quick Split's amount field at 37px tall and its delete button at 30px across,
on a page whose targets had never been measured;
- and on a 360x640 phone with the bar grown by a side pot, the bottom seat
sitting 38px inside it.
That last one brings back a cap this project measured and rejected once: capping
the felt to the room left fixed the six-seat ring and broke the nine-seat one.
That trade was real then and isn't now — seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
across 30 combinations of five phone sizes and 2 to 12 seats: every one clean
except twelve seats at 360x640, where a 240px felt and a 64px puck cannot both be
true. Where the rim can't give each seat a puck's width, the pucks come down a
size, measured from the rim itself rather than from a media query.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 51 +++++++--
.../wwwroot/css/casino/quicksplit.css | 10 +-
.../wwwroot/js/casino/holdem/actionBar.js | 4 +
.../wwwroot/js/casino/holdem/render.js | 29 +++++
tests/holdem/README.md | 17 ++-
tests/holdem/auditContrast.test.js | 108 ++++++++++++++++++
tests/holdem/runUiAudit.js | 93 +++++++++------
tests/holdem/uiAudit.js | 89 +++++++++++++--
8 files changed, 341 insertions(+), 60 deletions(-)
create mode 100644 tests/holdem/auditContrast.test.js
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 4903923..baf7970 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -586,12 +586,16 @@ button,
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
}
+/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: #8a8a8a
+ came out at 3.39:1 once the fade was composited, for the label a dealer reads to
+ answer "which seat is seat 6?". Lifted until it passes with a little to spare —
+ measured 4.5:1 at #a5a5a5 exactly, so it sits above that. */
.seatChairNumber {
position: absolute;
top: 2px;
left: 6px;
font-size: 0.58rem;
- color: #8a8a8a;
+ color: #aeaeae;
}
.seatPuckName {
@@ -2906,14 +2910,45 @@ body.hasActionBar #toastHost {
}
@media (max-width: 700px) {
- /* The narrow layout already had an answer for the bar: it reserves the bar's height
- as scroll room below the table, and a phone is a thing you scroll. Capping the
- felt here as well was measured and rejected — at 360x640 it took the table from
- 321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
- covered seats traded for two seats whose centre landed on a neighbour. The cap is
- for the wide layout, which never consulted the bar's height at all. */
+ /* The cap lives here now, and the reason it didn't is worth keeping.
+
+ It was measured and rejected once: at 360x640 it took the table from 321px to
+ 226px, which fixed the six-seat ring but broke the nine-seat one — two covered
+ seats traded for two seats whose centre landed on a neighbour. The narrow layout
+ fell back on reserving the bar's height as scroll room, on the grounds that a
+ phone is a thing you scroll.
+
+ That trade was real then and isn't now: seats are spaced by distance around the
+ rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
+ at 360x640 with the bar grown by a side pot, where the bottom seat sat 38px
+ inside it: capped, the felt is 240px and the ring clears the bar by 50, with the
+ audit clean at 6, 9 and 10 seats. Scroll room is not an answer for a seat that is
+ covered rather than merely below the fold. */
#pokerTableWrap {
- max-width: none;
+ max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
+ }
+
+ /* A felt small enough that the rim can't give each seat its own puck width. Set
+ from the measured rim in render.js rather than from the window, because the
+ question is how much table there is per player, which no media query can see:
+ nine seats on a 240px felt get 72px of rim each and the puck is 76 across. */
+ .seatsTight .seat {
+ width: 58px;
+ min-height: 38px;
+ padding: 3px 4px;
+ }
+
+ .seatsTight .seatPuckName {
+ margin-top: 0.35rem;
+ font-size: 0.62rem;
+ }
+
+ .seatsTight .seatPuckStack {
+ font-size: 0.55rem;
+ }
+
+ .seatsTight .seatPuckCards {
+ font-size: 0.58rem;
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
diff --git a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
index 257c887..252e3c7 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
@@ -148,9 +148,13 @@ button,
color: #8f8f8f;
}
+/* 37px tall, on the field a dealer types every amount into. The touch rule has been
+ 44px everywhere else since the round that set it; this page never got measured for
+ it, because the audit only ran the target check when asked and nothing asked. */
.unevenAmount {
flex: 1;
min-width: 0;
+ min-height: 46px;
background: none;
border: none;
outline: none;
@@ -180,10 +184,12 @@ button,
color: #ffd6d6;
}
+/* 30px across, and it deletes a player's entry — the one control on the row where a
+ near miss costs something. The amount field beside it gives up the 16px. */
.unevenRemoveBtn {
flex: none;
- width: 30px;
- height: 44px;
+ width: 46px;
+ height: 46px;
border: none;
background: none;
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 5455772..93c57d7 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -89,6 +89,10 @@ const ActionBar = {
document.documentElement.style.setProperty(
"--feltRoomPx",
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
+ // The felt has just changed size, so how much rim each seat gets has changed
+ // with it. Told rather than left to be noticed on the next render, which on a
+ // bar that grew mid-hand could be several actions away.
+ if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 3ec0850..bde0ef3 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -137,6 +137,34 @@ const Render = {
RING_STEPS: 720,
ringCache: {},
+ // How much rim each seat gets, in pixels. Ramanujan's approximation for an
+ // ellipse's perimeter, which is well inside a pixel at these sizes.
+ rimPerSeat(count) {
+ const wrap = document.getElementById("pokerTableWrap");
+ if (!wrap || !count) return Infinity;
+ const box = wrap.getBoundingClientRect();
+ const a = (box.width * 44) / 100;
+ const b = (box.height * 42) / 100;
+ if (!a || !b) return Infinity;
+ const perimeter = Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)));
+ return perimeter / count;
+ },
+
+ // A puck is 76px across. Under about that much rim each, neighbours start covering
+ // each other's middles, and a tap meant for one player lands on the next — so the
+ // pucks come down a size rather than the table pretending it has room it hasn't.
+ SEAT_ROOM_PX: 88,
+
+ // Called on every render and again whenever the felt is resized — the action bar
+ // growing mid-hand shortens the table, and the seats have to answer that without
+ // waiting for the next render to come along.
+ updateSeatDensity() {
+ const layer = document.getElementById("seatsLayer");
+ if (!layer || !this.state) return;
+ layer.classList.toggle("seatsTight",
+ this.rimPerSeat(this.state.seatCount) < this.SEAT_ROOM_PX);
+ },
+
// The angles that cut the rim into `count` equal lengths, starting at the top.
// Every seat on a ring wants the same answer, so it is worked out once per shape
// and kept — the ring only changes when the table is resized or the window is.
@@ -245,6 +273,7 @@ const Render = {
renderSeats() {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
+ this.updateSeatDensity();
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
diff --git a/tests/holdem/README.md b/tests/holdem/README.md
index 10ad8f7..9b2a211 100644
--- a/tests/holdem/README.md
+++ b/tests/holdem/README.md
@@ -36,7 +36,22 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen
no way to scroll to it, that no grid column has collapsed to nothing, and that no
text is painted outside its box. It runs over the table, all five overlays and Quick
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
-phones on their side.
+phones on their side — each viewport twice, once with a mouse and once with a thumb.
+
+That last part, and two of those checks, are newer than this paragraph claiming them.
+`runAll()` did not include the contrast check at all, and touch targets sat behind an
+opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
+eleven for a long run of rounds. Both were written and tested and simply never called.
+The mouse/thumb pair matters for the same reason: the 44px rule stands itself down
+where `hover: hover` says there is a pointer, and a headless desktop browser is a
+pointer, so a single pass had never measured a touch target at any viewport. Between
+them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete
+button on Quick Split.
+
+A check that decides it doesn't apply says so in the output rather than reporting no
+offenders, which reads identically to a pass. The same goes for the two exemptions
+that exist: a disabled control, which WCAG itself excuses, and a link inside a
+sentence, whose size is set by the line it sits in.
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
this one needs a browser and a running site. Run it after any change that adds or
diff --git a/tests/holdem/auditContrast.test.js b/tests/holdem/auditContrast.test.js
new file mode 100644
index 0000000..d942f23
--- /dev/null
+++ b/tests/holdem/auditContrast.test.js
@@ -0,0 +1,108 @@
+// The sums the contrast check runs on, tested away from a browser.
+//
+// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
+// and the arithmetic is the part that decides whether a real fault is reported or
+// waved through. Faded text used to be filed as "not checked" rather than measured;
+// this is the compositing that replaced that.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const LayoutAudit = require("./uiAudit");
+
+const WHITE = { r: 255, g: 255, b: 255, a: 1 };
+const BLACK = { r: 0, g: 0, b: 0, a: 1 };
+const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
+const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
+
+describe("the ratio itself", () => {
+ test("black on white is the full 21:1, either way round", () => {
+ assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
+ assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
+ });
+
+ test("a colour against itself is 1:1", () => {
+ assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
+ });
+});
+
+describe("text on a box that is being faded", () => {
+ // opacity on a box fades the box and its text together onto whatever is behind
+ // it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
+ // faded text both landing on the page.
+ const fade = alpha => ({ alpha, behind: PAGE });
+
+ test("at full strength nothing is composited", () => {
+ const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
+ assert.deepEqual(painted.fg, WHITE);
+ assert.deepEqual(painted.bg, PUCK);
+ });
+
+ test("fading moves both the text and its background, not just the text", () => {
+ const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
+ assert.ok(painted.fg.r < WHITE.r, "the text dims");
+ assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
+ assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
+ });
+
+ test("which is what stops a fade being reported as a fault it isn't", () => {
+ // Dimming light text on a dark puck over a dark page costs contrast, but far
+ // less than measuring the faded text against the *unfaded* puck would claim.
+ const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
+ const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
+ assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
+ LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
+ });
+
+ test("a fade all the way out lands everything on the page behind", () => {
+ const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
+ assert.deepEqual(painted.fg, PAGE);
+ assert.deepEqual(painted.bg, PAGE);
+ });
+
+ test("with nothing known to be behind it, the fade is left alone", () => {
+ // Better to measure the unfaded pair than to invent a backdrop.
+ const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
+ assert.deepEqual(painted.fg, WHITE);
+ assert.deepEqual(painted.bg, PUCK);
+ });
+});
+
+describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
+ // Deliberately not asserting a particular CSS colour here: the page's own greys are
+ // measured in the browser, against the real gradients, where they live. What this
+ // pins is the direction — that dimming a box costs its text contrast, which is why
+ // filing faded text as "not checked" hid faults rather than avoiding false ones.
+ test("the same grey scores lower once its box is dimmed", () => {
+ const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
+ const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
+ const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
+ assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
+ LayoutAudit.contrastRatio(solid.fg, solid.bg));
+ });
+
+ test("and a lighter grey scores higher at the same fade", () => {
+ const at = colour => {
+ const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
+ return LayoutAudit.contrastRatio(painted.fg, painted.bg);
+ };
+ assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
+ });
+});
+
+describe("reading a colour off the page", () => {
+ test("rgb and rgba both come back as numbers", () => {
+ assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
+ assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
+ });
+
+ test("a gradient gives up every stop, because text could be over any of them", () => {
+ const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
+ assert.equal(stops.length, 2);
+ assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
+ assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
+ });
+
+ test("no background image, no stops", () => {
+ assert.deepEqual(LayoutAudit.gradientStops("none"), []);
+ });
+});
diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js
index 33ba18f..08090bd 100644
--- a/tests/holdem/runUiAudit.js
+++ b/tests/holdem/runUiAudit.js
@@ -68,57 +68,74 @@ async function main() {
}
const browser = await chromium.launch();
- const page = await browser.newPage();
const failures = [];
const warnings = [];
let checks = 0;
- for (const [width, height, label] of VIEWPORTS) {
- await page.setViewportSize({ width, height });
+ // Twice over: once with a mouse, once with a thumb. The 44px rule stands itself
+ // down where `hover: hover` says there's a pointer, and a headless desktop browser
+ // is a pointer — so a single pass here has never once measured a touch target,
+ // however many viewports it visited. The phone-sized ones are exactly the pass that
+ // needs it.
+ for (const touch of [false, true]) {
+ const context = await browser.newContext(touch
+ ? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
+ : {});
+ const page = await context.newPage();
+ const hand = touch ? "touch" : "mouse";
- await page.goto(`${BASE}/Casino/TexasHoldem`);
- await page.evaluate(seedTable);
- await page.goto(`${BASE}/Casino/TexasHoldem`);
- await page.click("#nextHandBtn");
- await page.waitForTimeout(300);
- await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
- await page.addScriptTag({ path: AUDIT });
+ for (const [width, height, rawLabel] of VIEWPORTS) {
+ const label = `${rawLabel} · ${hand}`;
+ await page.setViewportSize({ width, height });
- const table = await page.evaluate(() => LayoutAudit.runAll());
- checks++;
- if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
- table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
+ await page.goto(`${BASE}/Casino/TexasHoldem`);
+ await page.evaluate(seedTable);
+ await page.goto(`${BASE}/Casino/TexasHoldem`);
+ await page.click("#nextHandBtn");
+ await page.waitForTimeout(300);
+ await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
+ await page.addScriptTag({ path: AUDIT });
- for (const [name, open] of OVERLAYS) {
- await page.evaluate(open);
- await page.waitForTimeout(180);
+ const table = await page.evaluate(() => LayoutAudit.runAll());
checks++;
- const result = await page.evaluate(() => {
- const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
- if (!overlay) return { offenders: [] };
- return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
- });
- await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
- if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
- }
- }
+ if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
+ table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
- for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
- await page.setViewportSize({ width, height });
- await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
- await page.evaluate(() => {
- const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
- if (button) button.click();
- });
- await page.addScriptTag({ path: AUDIT });
- checks++;
- const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
- if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
+ for (const [name, open] of OVERLAYS) {
+ await page.evaluate(open);
+ await page.waitForTimeout(180);
+ checks++;
+ const result = await page.evaluate(() => {
+ const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
+ if (!overlay) return { offenders: [] };
+ return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
+ });
+ await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
+ if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
+ }
+ }
+
+ for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
+ const label = `${rawLabel} · ${hand}`;
+ await page.setViewportSize({ width, height });
+ await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
+ await page.evaluate(() => {
+ const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
+ if (button) button.click();
+ });
+ await page.addScriptTag({ path: AUDIT });
+ checks++;
+ const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
+ if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
+ result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
+ }
+
+ await context.close();
}
await browser.close();
- console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
+ console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
warnings.forEach(w => console.log(` note: ${w}`));
if (failures.length === 0) {
console.log("clean");
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 7056cd6..ab5dd96 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -393,6 +393,37 @@ const LayoutAudit = {
}
},
+ // How much of this element actually reaches the screen, and what is behind the
+ // thing doing the fading.
+ //
+ // `opacity` on a box fades the whole box — its text and its own background together
+ // — onto whatever sits behind that box. So a dimmed seat is not "grey text on a
+ // dark puck"; it is a faded puck and faded text both landing on the felt. Both
+ // halves have to be composited the same way or the number means nothing.
+ fading(el) {
+ let alpha = 1;
+ let outermost = null;
+ for (let node = el; node; node = node.parentElement) {
+ const own = parseFloat(getComputedStyle(node).opacity);
+ if (own < 0.99) {
+ alpha *= own;
+ outermost = node;
+ }
+ }
+ if (!outermost) return { alpha: 1, behind: null };
+ return { alpha, behind: this.backgroundCandidates(outermost.parentElement || document.body)[0] || null };
+ },
+
+ // The two colours as the screen actually shows them, once the fading is applied.
+ asPainted(fgRaw, bg, fade) {
+ const solid = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
+ if (fade.alpha >= 0.99 || !fade.behind) return { fg: solid, bg };
+ return {
+ fg: this.over({ ...solid, a: solid.a * fade.alpha }, fade.behind),
+ bg: this.over({ ...bg, a: bg.a * fade.alpha }, fade.behind),
+ };
+ },
+
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];
@@ -426,12 +457,17 @@ const LayoutAudit = {
// exempt — there's nothing to read at zero — but anything in between is
// reported as unmeasured rather than passed over in silence, because a
// number nobody checked is not the same as a number that's fine.
- const opacity = parseFloat(style.opacity);
- if (opacity <= 0.02) return;
- const faded = opacity < 0.99 ||
- [...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
- if (faded) {
- unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
+ const fade = this.fading(el);
+ if (fade.alpha <= 0.02) return;
+
+ // A control that is switched off is the one exemption WCAG itself grants —
+ // an inactive component has no contrast requirement, which is why every
+ // toolkit dims them. Said out loud rather than skipped in silence: this is
+ // the only exemption in here, and the moment a dimmed thing turns out to be
+ // live it stops applying.
+ const off = el.closest("[disabled], [aria-disabled='true']");
+ if (off) {
+ unmeasured.push(`${this.describe(el)} :: disabled control, exempt from AA`);
return;
}
// aria-hidden used to exempt text here, on the grounds that decoration
@@ -459,8 +495,8 @@ const LayoutAudit = {
// The kindest reading available: best contrast across every candidate.
let best = 0;
candidates.forEach(bg => {
- const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
- best = Math.max(best, this.contrastRatio(fg, bg));
+ const painted = this.asPainted(fgRaw, bg, fade);
+ best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
});
const sizePx = parseFloat(style.fontSize);
@@ -543,6 +579,17 @@ const LayoutAudit = {
.filter(el => el.type !== "color")
.filter(el => !(exempt && el.matches(exempt)))
.forEach(el => {
+ // A link inside a sentence is the one target WCAG itself carves out:
+ // its size is set by the line it sits in, and padding a word out to
+ // 44px would break the paragraph around it. The rule is about controls
+ // a thumb goes hunting for, not words in prose. Said out loud, and
+ // narrow — display:inline, and real text either side of it.
+ if (el.tagName === "A" && getComputedStyle(el).display === "inline" &&
+ el.parentElement &&
+ [...el.parentElement.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) {
+ unmeasured.push(`${this.describe(el)} :: link inside a sentence, sized by the line`);
+ return;
+ }
// A control inside a scrolling sheet can sit below its container's
// visible area while still having a viewport rect. elementFromPoint at
// its centre then returns the sheet, effectiveTarget gives up, and the
@@ -674,6 +721,20 @@ const LayoutAudit = {
// ---- Runner ----
+ // Every check this file knows how to make, every time.
+ //
+ // Two of them used not to be here. Contrast was simply missing from the list, and
+ // touch targets sat behind an opt-in flag nobody passed — so "the audit is clean"
+ // has meant nine checks rather than eleven, and the two left out are the two whose
+ // faults a dealer meets in a dim room with their thumb. Both were written, tested
+ // and then never run: the contrast checker was built across two rounds and every
+ // sweep since has been silent about colour.
+ //
+ // A check that decides it can't apply here says so out loud rather than reporting
+ // an empty offenders list, which reads as a pass. touchTargets stands itself down
+ // on a mouse-driven browser — that is right, a 24px control is fine with a cursor
+ // on it — but "not measured" and "measured and fine" are different answers and the
+ // runner now prints which one it got.
runAll(options = {}) {
const checks = [
this.touchAction(),
@@ -686,8 +747,9 @@ const LayoutAudit = {
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
+ this.contrast(options.contrast),
+ this.touchTargets(options.minimumTarget, options.touchTargets),
];
- if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
return {
viewport: `${innerWidth}x${innerHeight}`,
@@ -695,8 +757,13 @@ const LayoutAudit = {
// Things worth knowing that aren't faults — see occlusion() on toasts.
// Controls a check couldn't probe go here too: not a fault, but not a pass
// either, and a silent skip is how faults hide.
- warnings: checks.flatMap(c => [...(c.warnings || []), ...(c.unmeasured || [])]),
- passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
+ warnings: checks.flatMap(c => [
+ ...(c.warnings || []),
+ ...(c.unmeasured || []),
+ ...(c.skipped ? [`${c.name}: not run here — ${c.skipped}`] : []),
+ ]),
+ passed: checks.filter(c => !c.skipped && c.offenders.length === 0).map(c => c.name),
+ skipped: checks.filter(c => c.skipped).map(c => c.name),
clean: checks.every(c => c.offenders.length === 0),
};
},
From dc5db16ffd091276307d179512dc35447a394e91 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 04:33:25 -0600
Subject: [PATCH 44/55] Let the phone table use the width it has
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Last round capped the felt so the ring would clear the action bar, and capped the
wrong dimension. The wide layout sizes the felt from its width, so capping the
width is how you shorten it there — on a phone the felt is already the full width
of the column, and capping that threw away 96px of a 360px screen: a 240x240
table inside a 336px space, with nine pucks crowding it.
A max-height instead leaves the width alone and lets the aspect ratio give way,
which turns a short screen into a wide oval — the shape a poker table is. At
360x640: 336x240 rather than 240x240, same clearance over the bar, and the
twelve-seat ring that was crowded at 240 comes out clean, which closes the one
combination left failing last round. Where the room isn't the binding constraint,
390x844 and up, nothing changes and the table stays round. The landscape branch
now says max-height: none, because it already spends the same measured room on
its own height and applying it twice took the felt to the floor value.
The measurement that decides whether pucks shrink was also wrong, in a way that
only showed up once it was asked a hard question. Rim length per seat measures
the walk between two seats, not the gap between them, and on a long oval the rim
doubles back at the ends: twelve seats at 640x360 had 193px of rim each and sat
60px apart. It now measures the closest two neighbours directly, off the ring
itself — and off the seat layer rather than the felt, because the layer is inset
by half a puck on a phone and the felt's box put the answer out by 30px.
That makes the rule geometric rather than about screen size, so it is no longer
scoped to a phone media query: a wide landscape felt crowds at its ends while its
rim claims there is room, and now it is told so.
Pucks still touch at nine and twelve seats on a small screen. Tried 46px against
58px: on a roomy phone 46 cleared every overlap where 58 left two, but in a hand
with a side pot, where the bar has grown and the felt has shrunk to suit, 46
overlapped four times and 58 twice. That is the felt being small, not the puck
being wide, and paying for it with an ellipsis on every long name buys nothing.
What has to hold is that each seat keeps its own centre, and that is measured at
every size.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 77 ++++++++++++-------
.../wwwroot/js/casino/holdem/render.js | 60 +++++++++------
tests/holdem/seatRing.test.js | 30 ++++++++
3 files changed, 118 insertions(+), 49 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index baf7970..017b86e 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -820,6 +820,38 @@ button,
outline-offset: 1px;
}
+/* Seats close enough together that full-size pucks would overlap. The class is set
+ from a measurement render.js takes off the ring itself — the closest two neighbours,
+ straight line — because no media query can see it: the same 390px phone is roomy at
+ six seats and crowded at nine, and a wide landscape felt crowds at its ends while
+ its rim says there is space. Not scoped to a screen size for the same reason. */
+.seatsTight .seat {
+ /* 58 was chosen by measurement and 46 was tried against it. On a roomy phone 46
+ cleared every overlap at nine seats where 58 left two — but in the state that
+ actually matters, a hand with a side pot where the bar has grown and the felt has
+ shrunk to suit, 46 overlapped four times and 58 twice. Pucks touching at that
+ density is the felt being small, not the puck being wide, and paying for it with
+ an ellipsis on every long name buys nothing. What has to hold is that each seat
+ keeps its own centre, so a tap lands on the player it was aimed at, and that is
+ measured at every size. */
+ width: 58px;
+ min-height: 38px;
+ padding: 3px 4px;
+}
+
+.seatsTight .seatPuckName {
+ margin-top: 0.35rem;
+ font-size: 0.62rem;
+}
+
+.seatsTight .seatPuckStack {
+ font-size: 0.55rem;
+}
+
+.seatsTight .seatPuckCards {
+ font-size: 0.58rem;
+}
+
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
@@ -2921,34 +2953,20 @@ body.hasActionBar #toastHost {
That trade was real then and isn't now: seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
at 360x640 with the bar grown by a side pot, where the bottom seat sat 38px
- inside it: capped, the felt is 240px and the ring clears the bar by 50, with the
- audit clean at 6, 9 and 10 seats. Scroll room is not an answer for a seat that is
- covered rather than merely below the fold. */
+ inside it: the ring now clears the bar by 50. Scroll room is not an answer for a
+ seat that is covered rather than merely below the fold.
+
+ The room is spent on height, not on both. Capping the *width* by it — the wide
+ layout's way, because there the felt is sized from its width — kept the table
+ square and threw away 96px of a 360px screen: 240x240 inside a 336px column. A
+ max-height leaves the width alone and lets the aspect ratio give way, so a short
+ screen gets a wide oval, which is the shape a poker table is anyway. Measured at
+ 360x640: 336x240 rather than 240x240, same clearance, and the twelve-seat ring
+ that was crowded at 240 comes out clean. Where the room isn't the binding
+ constraint — 390x844 and up — nothing changes and the table stays round. */
#pokerTableWrap {
- max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
- }
-
- /* A felt small enough that the rim can't give each seat its own puck width. Set
- from the measured rim in render.js rather than from the window, because the
- question is how much table there is per player, which no media query can see:
- nine seats on a 240px felt get 72px of rim each and the puck is 76 across. */
- .seatsTight .seat {
- width: 58px;
- min-height: 38px;
- padding: 3px 4px;
- }
-
- .seatsTight .seatPuckName {
- margin-top: 0.35rem;
- font-size: 0.62rem;
- }
-
- .seatsTight .seatPuckStack {
- font-size: 0.55rem;
- }
-
- .seatsTight .seatPuckCards {
- font-size: 0.58rem;
+ max-width: none;
+ max-height: var(--feltRoomPx, 100vh);
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
@@ -3312,6 +3330,11 @@ body.hasActionBar #toastHost {
a table that is already as short as the screen allows. */
aspect-ratio: auto;
max-width: none;
+ /* This branch already spends the measured room on its own height. The phone
+ block's max-height is the same number arriving twice, and applying it here
+ took the felt down to the floor value and crowded the ring — measured at
+ 640x360, two seats lost their centres to their neighbours. */
+ max-height: none;
}
/* The page's own top padding is 32px of nothing, on the screen that has the least
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index bde0ef3..e5a76ad 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -91,12 +91,21 @@ const Render = {
// circle when there's nothing to measure — the same answer the ring gave before it
// asked.
feltAspect() {
- if (typeof document === "undefined") return 1;
- const wrap = document.getElementById("pokerTableWrap");
- if (!wrap) return 1;
- const box = wrap.getBoundingClientRect();
- if (!box.width || !box.height) return 1;
- return box.width / box.height;
+ const box = this.ringBox();
+ return box ? box.width / box.height : 1;
+ },
+
+ // The box a seat's left/top percentages actually resolve against. Not the felt: on
+ // a phone the seat layer is inset by half a puck so the outermost seats don't hang
+ // off the screen, and measuring the felt instead put the ring's shape out by that
+ // inset — twelve seats at 640x360 were computed 90px apart and drawn 60px apart,
+ // which is the difference between "fine" and "overlapping".
+ ringBox() {
+ if (typeof document === "undefined") return null;
+ const layer = document.getElementById("seatsLayer") || document.getElementById("pokerTableWrap");
+ if (!layer) return null;
+ const box = layer.getBoundingClientRect();
+ return box.width && box.height ? box : null;
},
// Bets sit on the felt between their player and the pot, the way chips actually
@@ -137,23 +146,30 @@ const Render = {
RING_STEPS: 720,
ringCache: {},
- // How much rim each seat gets, in pixels. Ramanujan's approximation for an
- // ellipse's perimeter, which is well inside a pixel at these sizes.
- rimPerSeat(count) {
- const wrap = document.getElementById("pokerTableWrap");
- if (!wrap || !count) return Infinity;
- const box = wrap.getBoundingClientRect();
- const a = (box.width * 44) / 100;
- const b = (box.height * 42) / 100;
- if (!a || !b) return Infinity;
- const perimeter = Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)));
- return perimeter / count;
+ // The closest two neighbouring seats come to each other, straight line, in pixels.
+ //
+ // Rim length divided by seat count was the first answer and it is the wrong one:
+ // it measures the walk between two seats rather than the gap between them, and on
+ // a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
+ // each and sat 56px apart, because at the ends of a long oval the rim doubles back
+ // and two seats a long walk apart are stacked one above the other.
+ closestSeatGapPx(count, box = this.ringBox()) {
+ if (count < 2 || !box || !box.width || !box.height) return Infinity;
+
+ const points = this.ringAngles(count, 44, 42, box.width / box.height).map(angle => ({
+ x: ((50 + 44 * Math.cos(angle)) / 100) * box.width,
+ y: ((50 + 42 * Math.sin(angle)) / 100) * box.height,
+ }));
+ return points.reduce((closest, point, i) => {
+ const next = points[(i + 1) % points.length];
+ return Math.min(closest, Math.hypot(point.x - next.x, point.y - next.y));
+ }, Infinity);
},
- // A puck is 76px across. Under about that much rim each, neighbours start covering
- // each other's middles, and a tap meant for one player lands on the next — so the
- // pucks come down a size rather than the table pretending it has room it hasn't.
- SEAT_ROOM_PX: 88,
+ // A puck is 76px across, so neighbours whose centres are closer than that overlap
+ // by definition; a few pixels more keeps them from touching. Under it the pucks
+ // come down a size rather than the table pretending it has room it hasn't.
+ SEAT_ROOM_PX: 84,
// Called on every render and again whenever the felt is resized — the action bar
// growing mid-hand shortens the table, and the seats have to answer that without
@@ -162,7 +178,7 @@ const Render = {
const layer = document.getElementById("seatsLayer");
if (!layer || !this.state) return;
layer.classList.toggle("seatsTight",
- this.rimPerSeat(this.state.seatCount) < this.SEAT_ROOM_PX);
+ this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
},
// The angles that cut the rim into `count` equal lengths, starting at the top.
diff --git a/tests/holdem/seatRing.test.js b/tests/holdem/seatRing.test.js
index 7ed6901..72f5f94 100644
--- a/tests/holdem/seatRing.test.js
+++ b/tests/holdem/seatRing.test.js
@@ -92,6 +92,36 @@ describe("a felt wider than it is tall", () => {
});
});
+describe("how close the closest two seats come", () => {
+ // What decides whether the pucks shrink. Rim length per seat was the first answer
+ // and it is the wrong question: it measures the walk between two seats, not the
+ // gap. On a long oval the rim doubles back at the ends, so two seats a long walk
+ // apart end up stacked one above the other.
+ const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
+
+ test("a flat table's ends are far closer than its rim suggests", () => {
+ // 616x96: the landscape felt on a 640x360 phone once the bar has grown.
+ const rim = Math.PI * (3 * (271 + 40) - Math.sqrt((3 * 271 + 40) * (271 + 3 * 40))) / 6;
+ assert.ok(gap(6, 616, 96) < rim / 3, `${gap(6, 616, 96).toFixed(0)}px apart, ${rim.toFixed(0)}px of rim each`);
+ });
+
+ test("a round table's neighbours are about as far apart as the rim says", () => {
+ const rim = (Math.PI * 2 * 0.43 * 400) / 9;
+ assert.ok(Math.abs(gap(9, 400, 400) - rim) < rim * 0.15);
+ });
+
+ test("more seats, less room, in that order", () => {
+ assert.ok(gap(12, 400, 400) < gap(9, 400, 400));
+ assert.ok(gap(9, 400, 400) < gap(6, 400, 400));
+ assert.ok(gap(6, 400, 400) < gap(6, 800, 800), "and a bigger table gives more");
+ });
+
+ test("nothing to measure between fewer than two seats", () => {
+ assert.equal(gap(1, 400, 400), Infinity);
+ assert.equal(gap(9, 0, 0), Infinity);
+ });
+});
+
describe("the shape of the ring", () => {
test("is a mirror image left to right", () => {
const seats = ringPixels(10, 601, 126);
From 8aa90e90a4b823076eb159b92ab416a1da447758 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 04:58:59 -0600
Subject: [PATCH 45/55] Give a phone on its side the table shape it needs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The last two rounds left one thing failing and said so: a landscape phone with
six or twelve seats stacked two of them 25px apart at each end of the felt, with
pucks 64px tall. It has been there since seats were first spaced by distance
rather than by angle. Three things were wrong at once, and it needed all three.
The rim is the wrong shape when the table is very flat. An ellipse 616px wide and
96px tall has nowhere to put the seats near its ends: the curve doubles back on
itself, so two seats a long walk apart come out one above the other. Past two and
a half times wider than tall the rim is now a stadium — two straight rails with a
half-round cap at each end, which is how a real poker table is built and exactly
why it is built that way. A cap is a half-circle of the felt's own height, so an
end takes one seat and the rest is rail. Only past that ratio: measured, the
cut-off lands between a landscape phone at 4.4 and every other layout the tool
has, 1.45 at 1280x900 and 1.0 to 1.4 upright, so no working table changes shape.
The ring had less height than the felt did. The seat layer is inset by half a
puck to keep the outermost seats on screen, and 26px off the top and bottom of a
96px felt left 44px to work with — less than half the table, which is why no rim
shape could have fixed it alone. The inset is a percentage where the felt is
short. The landscape branch never had that rule at all, because it is matched on
height while the inset lives in a width query, so an 844px-wide phone on its side
drew its ring against the felt's whole box; with a flat-topped stadium rail that
put a dealer button over Resolve Pot in the header.
And the puck was a line too tall. In the tight state the hole cards give up their
row: of the four things on a puck, the cards are the one the dealer is holding in
their other hand, and the seat editor still shows them. That took the puck from
64px to 55px, which is what finally cleared the ends.
Measured across 36 combinations of nine viewports and 2, 6, 9 and 12 seats: the
closest two seats at 640x360 go from 25px apart to 55px, and every combination is
clean. Then 216 audits across thirteen viewports, three seat counts and both a
mouse and a thumb: no failures, no console errors.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 29 ++++++-
.../wwwroot/js/casino/holdem/render.js | 83 ++++++++++++++++---
tests/holdem/seatRing.test.js | 72 +++++++++++++++-
3 files changed, 167 insertions(+), 17 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 017b86e..4d9912e 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -848,8 +848,13 @@ button,
font-size: 0.55rem;
}
+/* The hole cards give up their line here. A puck this close to its neighbours is 64px
+ tall and the seats at the ends of a landscape felt are 55px apart, so the line has to
+ come from somewhere — and of the four things on a puck, the cards are the one the
+ dealer is holding in their other hand. Name, bet and stack stay. The seat editor
+ still shows the cards in full. */
.seatsTight .seatPuckCards {
- font-size: 0.58rem;
+ display: none;
}
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
@@ -3038,10 +3043,18 @@ body.hasActionBar #toastHost {
}
/* Keeps every puck inside the viewport: the ring is drawn within this box,
- so half a puck of padding is exactly enough to stop the overhang. */
+ so half a puck of padding is exactly enough to stop the overhang.
+
+ Half a puck is the right answer when the felt is tall enough to spare it. On a
+ phone held sideways it isn't: the felt is 96px tall there once the action bar has
+ grown, and 26px off the top and bottom left the ring 44px of height to work with
+ — less than half the table. That is what kept the seats at the ends stacked no
+ matter what shape the rim was.
+
+ Above about 216px of felt the flat 26px still wins, so nothing upright changes. */
#seatsLayer,
#betsLayer {
- inset: 26px 44px;
+ inset: min(26px, 12%) 44px;
}
/* Narrower than the desktop puck: at a full ten-handed table the ring is
@@ -3410,6 +3423,16 @@ body.hasActionBar #toastHost {
min-height: 40px;
}
+ /* The same inset the phone layout uses, which this branch never had because it is
+ matched on height rather than width — so an 844px-wide phone on its side drew its
+ ring against the felt's full box. With the rim a stadium, the top rail is flat,
+ which put the button seat's dealer chip over the Resolve Pot button in the header
+ above. Percentages of a short felt, so the ring keeps the height it needs. */
+ #seatsLayer,
+ #betsLayer {
+ inset: min(26px, 12%) 44px;
+ }
+
}
/* ---------- Short phones, upright ----------
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index e5a76ad..566bb15 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -117,8 +117,11 @@ const Render = {
// anything visible — it is just the honest way to say "on that player's spoke",
// and it saves the second walk.
betPosition(index, count, aspect = this.feltAspect()) {
- const angle = this.ringAngles(count, 44, 42, aspect)[index];
- return { left: 50 + 27 * Math.cos(angle), top: 50 + 25 * Math.sin(angle) };
+ const seat = this.seatPosition(index, count, aspect);
+ return {
+ left: 50 + (seat.left - 50) * (27 / 44),
+ top: 50 + (seat.top - 50) * (25 / 42),
+ };
},
// Seats spaced evenly around the rim — by the distance between them, not by the
@@ -136,11 +139,71 @@ const Render = {
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
// few hundred pixels long, and it runs once per render.
positionOnRing(index, count, rx, ry, aspect = 1) {
- const target = this.ringAngles(count, rx, ry, aspect)[index];
- return {
- left: 50 + rx * Math.cos(target),
- top: 50 + ry * Math.sin(target),
- };
+ return this.ringPositions(count, rx, ry, aspect)[index];
+ },
+
+ // Past this much wider than tall, the rim stops being an ellipse and becomes a
+ // stadium — two straight rails with a semicircular cap at each end.
+ //
+ // An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with
+ // the bar grown by a side pot the felt is 616x96, and six seats came out with two
+ // of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px:
+ // the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so
+ // an end holds one seat and the rest of the table is rail — which is how a real
+ // poker table is built, and why it is shaped that way.
+ //
+ // Only where it is needed. Anything rounder than this keeps the ellipse it has
+ // always had: measured, the cut-off lands between a landscape phone (4.4 and up)
+ // and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone
+ // upright), so no currently-working table changes shape.
+ STADIUM_RATIO: 2.5,
+
+ // Seats spaced evenly around whichever rim this table has, as percentages.
+ ringPositions(count, rx, ry, aspect = 1) {
+ if (count <= 0) return [];
+ const halfWidth = rx * aspect;
+ const halfHeight = ry;
+ if (halfWidth <= halfHeight * this.STADIUM_RATIO) {
+ return this.ringAngles(count, rx, ry, aspect).map(angle => ({
+ left: 50 + rx * Math.cos(angle),
+ top: 50 + ry * Math.sin(angle),
+ }));
+ }
+ return this.stadiumPositions(count, halfWidth, halfHeight, aspect);
+ },
+
+ // The same walk, around a stadium instead. Its perimeter has a closed form, so
+ // this needs no sampling: two rails of 2(A-B) and two caps of piB.
+ stadiumPositions(count, halfWidth, halfHeight, aspect) {
+ const rail = halfWidth - halfHeight;
+ const cap = Math.PI * halfHeight;
+ const perimeter = 4 * rail + 2 * cap;
+
+ return Array.from({ length: count }, (_, seat) => {
+ // Clockwise from the top centre, so seat one sits where it always has.
+ let walked = (perimeter * seat) / count;
+ let x;
+ let y;
+ if (walked <= rail) { // top rail, going right
+ x = walked;
+ y = -halfHeight;
+ } else if (walked <= rail + cap) { // right cap
+ const turned = (walked - rail) / halfHeight;
+ x = rail + halfHeight * Math.sin(turned);
+ y = -halfHeight * Math.cos(turned);
+ } else if (walked <= 3 * rail + cap) { // bottom rail, going left
+ x = rail - (walked - rail - cap);
+ y = halfHeight;
+ } else if (walked <= 3 * rail + 2 * cap) { // left cap
+ const turned = (walked - 3 * rail - cap) / halfHeight;
+ x = -rail - halfHeight * Math.sin(turned);
+ y = halfHeight * Math.cos(turned);
+ } else { // top rail, back to the start
+ x = -rail + (walked - 3 * rail - 2 * cap);
+ y = -halfHeight;
+ }
+ return { left: 50 + x / aspect, top: 50 + y };
+ });
},
RING_STEPS: 720,
@@ -156,9 +219,9 @@ const Render = {
closestSeatGapPx(count, box = this.ringBox()) {
if (count < 2 || !box || !box.width || !box.height) return Infinity;
- const points = this.ringAngles(count, 44, 42, box.width / box.height).map(angle => ({
- x: ((50 + 44 * Math.cos(angle)) / 100) * box.width,
- y: ((50 + 42 * Math.sin(angle)) / 100) * box.height,
+ const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
+ x: (seat.left / 100) * box.width,
+ y: (seat.top / 100) * box.height,
}));
return points.reduce((closest, point, i) => {
const next = points[(i + 1) % points.length];
diff --git a/tests/holdem/seatRing.test.js b/tests/holdem/seatRing.test.js
index 72f5f94..ad9f62a 100644
--- a/tests/holdem/seatRing.test.js
+++ b/tests/holdem/seatRing.test.js
@@ -99,10 +99,27 @@ describe("how close the closest two seats come", () => {
// apart end up stacked one above the other.
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
- test("a flat table's ends are far closer than its rim suggests", () => {
- // 616x96: the landscape felt on a 640x360 phone once the bar has grown.
- const rim = Math.PI * (3 * (271 + 40) - Math.sqrt((3 * 271 + 40) * (271 + 3 * 40))) / 6;
- assert.ok(gap(6, 616, 96) < rim / 3, `${gap(6, 616, 96).toFixed(0)}px apart, ${rim.toFixed(0)}px of rim each`);
+ test("a flat table no longer stacks two seats at each end", () => {
+ // 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
+ // ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
+ // is a stadium at this shape, so an end holds one seat.
+ assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
+ assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
+ });
+
+ test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
+ // Same felt, forced through the ellipse the tool uses for rounder tables.
+ const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
+ x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
+ y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
+ }));
+ const closest = asEllipse.reduce((least, point, i) => {
+ const next = asEllipse[(i + 1) % asEllipse.length];
+ return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
+ }, Infinity);
+ assert.ok(closest < 70, `${closest.toFixed(0)}px`);
+ assert.ok(gap(6, 616, 96) > closest * 1.3,
+ `stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
});
test("a round table's neighbours are about as far apart as the rim says", () => {
@@ -122,6 +139,53 @@ describe("how close the closest two seats come", () => {
});
});
+describe("which rim a table gets", () => {
+ // An ellipse until the table is much wider than tall, then a stadium: two rails
+ // and a half-round cap at each end, which is how a real poker table is built.
+ const shape = (width, height) => {
+ const aspect = width / height;
+ return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
+ };
+
+ test("every layout the tool had keeps the ellipse", () => {
+ assert.equal(shape(844, 608), "ellipse", "desktop");
+ assert.equal(shape(600, 600), "ellipse", "tablet");
+ assert.equal(shape(278, 314), "ellipse", "phone upright");
+ assert.equal(shape(336, 240), "ellipse", "short phone upright");
+ });
+
+ test("a phone on its side gets the stadium", () => {
+ assert.equal(shape(528, 73), "stadium");
+ assert.equal(shape(780, 113), "stadium");
+ });
+
+ test("the cap holds one seat instead of stacking two", () => {
+ // Six seats on a 616x96 rim: the walk between neighbours has to be longer than
+ // the cap, or two of them end up on the same half-circle.
+ const halfWidth = 44 * (616 / 96), halfHeight = 42;
+ const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
+ assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
+ });
+
+ test("seat one is still at the top centre, and the ring still mirrors", () => {
+ const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
+ assert.ok(Math.abs(seats[0].left - 50) < 0.01);
+ assert.ok(seats[0].top < 50);
+ for (let seat = 1; seat < 4; seat++) {
+ const mirrored = seats[8 - seat];
+ assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
+ assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
+ }
+ });
+
+ test("nobody is drawn off the felt", () => {
+ Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
+ assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
+ assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
+ });
+ });
+});
+
describe("the shape of the ring", () => {
test("is a mirror image left to right", () => {
const seats = ringPixels(10, 601, 126);
From c19575ab10ffe4c895d22b16bb0fbb83a46ab55a Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 05:12:33 -0600
Subject: [PATCH 46/55] Count the house's cut instead of calling it missing
chips
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A game that takes a rake has chips leaving the table every hand, and the bank
check had no idea. It added up the buy-ins, added up the chips on the felt, and
told the dealer somebody had been paid for chips they were never given — a
shortfall that grows all night and points at a miscount that never happened.
Every home game that takes a drop for the beer fund hits this on hand one.
So the tool takes it, rather than watching it go. Under Table Setup: a
percentage, an optional cap, and "no flop, no drop" — the rule almost everyone
plays, on by default, where a hand that ends before the flop pays nothing. Zero
percent is a game that doesn't rake, which is most of them and the default, and
nothing about that game changes.
Where it shows up is the point. The pot wizard says "House takes $2 of $40 — 5%,
capped at $5" above the payouts, before a chip moves, because the summary is the
dealer's last chance to disagree with the arithmetic and money taken quietly is
the one thing a tool like this must never do. The bank check gets a House take
line and says "balanced ... once the $2 in the house's cut is counted". The hand
log keeps the pot as the table saw it with the cut recorded beside it, and the
export has a column for it, so the night's total is one sum in a spreadsheet.
Two decisions worth stating. The rake rounds *down* to a chip the table actually
has: a house that can't make 40c takes nothing and the players keep it, which is
the only way to be wrong about this that nobody argues with. And on a split pot
it comes off the top, then what is left is shared in proportion — with the
rounding leftovers going back to the winners rather than to the house, which
asked for a number and gets that number, not that number plus dust.
Measured end to end through the UI: 5% capped at $5 on a $40 pot pays $38 and
banks $2; four stacks plus the cut come to exactly the $800 bought in; the bank
check reads balanced. With no-flop-no-drop on, a hand folded round pre-flop pays
the full $15 and takes nothing; with it off the same hand pays $14 and takes $1.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 30 +++
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 14 ++
.../js/casino/holdem/handHistoryExport.js | 7 +-
.../wwwroot/js/casino/holdem/main.js | 29 ++-
.../wwwroot/js/casino/holdem/money.js | 15 ++
.../wwwroot/js/casino/holdem/potWizard.js | 45 +++-
.../wwwroot/js/casino/holdem/render.js | 33 ++-
.../wwwroot/js/casino/holdem/state.js | 92 ++++++-
tests/holdem/rake.test.js | 230 ++++++++++++++++++
9 files changed, 479 insertions(+), 16 deletions(-)
create mode 100644 tests/holdem/rake.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index d2bb9d2..558d334 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -86,6 +86,32 @@
Blinds post themselves each hand. Seats without their own stack start here.
+
+ House take
+
+
+
+
+
+
+
+ Taken from the pot when it's awarded, and shown before anything is pushed.
+ Rounded down to a chip the table has, so the players keep what the house
+ can't make. Leave the percentage at zero for a game that doesn't rake.
+
+
+
+
+
Without live betting
@@ -132,6 +158,10 @@
Taken off the table
+
+ House take
+
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 4d9912e..b2f67c2 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -2898,6 +2898,20 @@ body.hasActionBar #toastHost {
color: #ff6b6b;
}
+/* What the house takes, above the payouts it comes out of. Stated in the summary
+ rather than only in the log afterwards: this is the dealer's last chance to
+ disagree with the arithmetic before the chips move, and money taken quietly is the
+ one thing a tool like this must never do. */
+.potWizardRake {
+ margin: 0 0 0.7rem;
+ padding: 0.45rem 0.6rem;
+ border-radius: 8px;
+ background: rgba(216, 180, 106, 0.1);
+ border: 1px solid rgba(216, 180, 106, 0.35);
+ color: #e8cf9a;
+ font-size: 0.82rem;
+}
+
.potWizardSummary ul {
list-style: none;
padding: 0;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
index 0257d3f..1d0bf38 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
@@ -20,7 +20,11 @@ const HandHistoryExport = {
// who owes what — doesn't work without cleaning every cell first. The commas were
// quoted correctly, so the file parsed; it just wouldn't add up.
toCsv(handHistory, standings = [], { notes = "" } = {}) {
- const rows = [["Hand #", "Time", "Pot", "Payouts"]];
+ // The rake gets its own column rather than being folded into the pot: the two
+ // numbers answer different questions — what was in the middle, and what the
+ // house took out of it — and a night's worth of the second one is what the box
+ // is counted against.
+ const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
@@ -30,6 +34,7 @@ const HandHistoryExport = {
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.forSheet(entry.pot),
+ Money.forSheet(entry.rake || 0),
payouts,
]);
});
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 6439cf9..327cc9a 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -192,11 +192,15 @@ document.addEventListener("DOMContentLoaded", () => {
hooks.save(state);
Render.renderAll();
- PotWizard.open(state, payouts => {
+ PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
- runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
+ const withRake = rake > 0 ? `${paid}, house ${Money.format(rake)}` : paid;
+ runUndoable(withRake ? `Paid ${withRake}.` : "Hand closed with nothing to pay out.", () => {
+ if (rake > 0) {
+ state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
+ }
if (payouts.length > 0) {
- HoldemState.recordHand(state, payouts);
+ HoldemState.recordHand(state, payouts, rake);
// Winnings go straight back in front of the player, so the next
// hand's stacks are already right.
Betting.creditWinnings(state, payouts);
@@ -462,6 +466,25 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
+ // The house's cut. Kept as a group rather than three loose fields, because
+ // migrate() guarantees the shape and half a rake object would be a table nobody
+ // could read back.
+ const readRake = () => {
+ const percent = Number(document.getElementById("rakePercentInput").value);
+ const cap = Number(document.getElementById("rakeCapInput").value);
+ state.rake = {
+ percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0,
+ cap: Number.isFinite(cap) && cap > 0 ? Money.round(cap) : null,
+ noFlopNoDrop: document.getElementById("rakeNoFlopInput").checked,
+ };
+ hooks.save(state);
+ Render.renderAll();
+ };
+ ["rakePercentInput", "rakeCapInput"].forEach(id => {
+ document.getElementById(id).addEventListener("input", readRake);
+ });
+ document.getElementById("rakeNoFlopInput").addEventListener("change", readRake);
+
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js
index f0fe135..1d03ef4 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js
@@ -86,6 +86,21 @@ const Money = {
return this.fromCents(Math.ceil(cents / step) * step);
},
+ // And down, for money coming *out* of a pot rather than going into it.
+ //
+ // The rake is the one amount in this tool that leaves the table, so the direction
+ // of the rounding is a decision about whose money it is. Down: a house that can't
+ // make 40c out of its chips takes nothing rather than a dollar, and the players
+ // keep the difference. Being wrong in the players' favour is the only way to be
+ // wrong about this that nobody has to argue about.
+ downToChip(amount, legend) {
+ const step = this.chipStepCents(legend);
+ if (step <= 0) return this.round(amount);
+ const cents = this.toCents(amount);
+ if (cents <= 0) return 0;
+ return this.fromCents(Math.floor(cents / step) * step);
+ },
+
chipBreakdown(amount, legend) {
let cents = this.toCents(amount);
if (cents <= 0) return { chips: [], remainder: 0 };
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
index 036a4ca..7ab20d4 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
@@ -232,6 +232,24 @@ const PotWizard = {
return wrap;
},
+ // What the house takes out of this pot, and what is left to push.
+ //
+ // Worked out here rather than at the end so the dealer sees it before anything
+ // moves: the summary is the last chance to disagree with the arithmetic, and a
+ // rake that only appeared in the hand log afterwards would be the tool taking
+ // money quietly.
+ rakeSplit() {
+ const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
+ id,
+ label: this.playerLabelById.get(id),
+ amount,
+ }));
+ const pot = gross.reduce((sum, p) => sum + p.amount, 0);
+ const sawFlop = (this.state.street || "preflop") !== "preflop";
+ const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
+ return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
+ },
+
renderSummary() {
const wrap = document.createElement("div");
wrap.className = "potWizardSummary";
@@ -240,6 +258,20 @@ const PotWizard = {
heading.textContent = "Final Payouts";
wrap.appendChild(heading);
+ const split = this.rakeSplit();
+ const rakeSet = Number((this.state.rake || {}).percent) > 0;
+ if (rakeSet && this.totals.size > 0) {
+ const line = document.createElement("p");
+ line.className = "potWizardRake";
+ const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
+ line.textContent = split.rake > 0
+ ? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
+ : split.sawFlop
+ ? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
+ : "No flop, no drop — nothing taken from a pot that ended before the flop.";
+ wrap.appendChild(line);
+ }
+
if (this.totals.size === 0) {
const empty = document.createElement("p");
empty.className = "fieldHint";
@@ -248,7 +280,7 @@ const PotWizard = {
} else {
const legend = (this.state && this.state.chipLegend) || [];
const list = document.createElement("ul");
- for (const [id, amount] of this.totals.entries()) {
+ for (const { id, amount } of split.payouts) {
const li = document.createElement("li");
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
@@ -275,13 +307,12 @@ const PotWizard = {
applyBtn.className = "holdemBtn holdemBtnAccent";
applyBtn.textContent = "Start Next Hand";
applyBtn.addEventListener("click", () => {
- const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
- id,
- label: this.playerLabelById.get(id),
- amount,
- }));
+ // Recomputed rather than reused from the render: the same numbers, but
+ // taken at the moment the dealer commits rather than when they last
+ // looked at the screen.
+ const paid = this.rakeSplit();
this.close();
- this.onFinished(payouts);
+ this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
});
actions.appendChild(applyBtn);
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 566bb15..19bf744 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -47,6 +47,19 @@ const Render = {
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
+
+ const rake = this.state.rake || {};
+ document.getElementById("rakePercentInput").value = rake.percent || "";
+ document.getElementById("rakeCapInput").value = rake.cap ?? "";
+ document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
+
+ // What the house has actually taken tonight, said where the rake is set rather
+ // than only in the bank check — a dealer who changes the percentage mid-session
+ // wants to see the running total right there.
+ const taken = Math.max(0, Number(this.state.rakeTaken) || 0);
+ const hint = document.getElementById("rakeTakenHint");
+ hint.hidden = taken <= 0;
+ if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
},
// Whether this device can actually drag a seat. HTML5 drag events are never
@@ -1145,10 +1158,26 @@ const Render = {
}
}
+ // Same idea for the house's cut, and the more important of the two: a raked
+ // game moves chips off the table every single hand, so without this line the
+ // check reads as a shortfall that grows all night.
+ const rakedRow = document.getElementById("bankRakedRow");
+ if (rakedRow) {
+ rakedRow.hidden = !(bank.raked > 0);
+ if (bank.raked > 0) {
+ document.getElementById("bankRaked").textContent = Money.format(bank.raked);
+ }
+ }
+
const verdict = document.getElementById("bankReconcileVerdict");
+ // What has left the table on purpose, whichever way it went.
+ const accountedFor = [
+ bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
+ bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
+ ].filter(Boolean);
if (bank.balanced) {
- verdict.textContent = bank.discarded > 0
- ? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
+ verdict.textContent = accountedFor.length > 0
+ ? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index d3404ae..34cfa20 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -104,6 +104,12 @@ const HoldemState = {
// announced hand by hand, so this is cleared with every other one-deal
// decision rather than standing until somebody turns it off.
straddleCount: 0,
+ // What the house takes, if it takes anything. Percent zero is a game that
+ // doesn't rake, which is nearly all of them and the default.
+ rake: { percent: 0, cap: null, noFlopNoDrop: true },
+ // What it has taken so far tonight. A session total, not a per-hand one:
+ // it is the number the box is checked against at the end.
+ rakeTaken: 0,
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
@@ -213,6 +219,15 @@ const HoldemState = {
// whatever is in front of those players is already in their committed
// chips, and nothing is owed on top of it.
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
+ // A table saved before the house took anything takes nothing, which is
+ // also the right answer for one that never will.
+ const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
+ parsed.rake = {
+ percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
+ cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
+ noFlopNoDrop: rake.noFlopNoDrop !== false,
+ };
+ parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
@@ -679,11 +694,15 @@ const HoldemState = {
// Must be called before startNextHand() clears the seats: the per-player `ledger`
// it stores alongside the payouts is what session standings are summed from later,
// and it needs this hand's contributions while they're still on the table.
- recordHand(state, payouts) {
+ recordHand(state, payouts, rake = 0) {
if (typeof state.handNumber !== "number") state.handNumber = 1;
if (!Array.isArray(state.handHistory)) state.handHistory = [];
- const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
+ // The pot as it was, house cut included — the number the table saw in the
+ // middle. What each player took is separately recorded and is net of it, so
+ // the two together say where every chip went.
+ const taken = Money.round(Math.max(0, Number(rake) || 0));
+ const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken);
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
const ledger = state.seats
.map((player, index) => ({
@@ -698,6 +717,7 @@ const HoldemState = {
handNumber: state.handNumber,
timestamp: Date.now(),
pot,
+ rake: taken,
payouts,
ledger,
});
@@ -818,6 +838,67 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
+ // ---- The house's cut ----
+ //
+ // A game that takes a rake or a time charge has chips leaving the table every hand,
+ // and until now the bank check called them missing: it added up the buy-ins,
+ // added up the chips on the felt, and told the dealer somebody had been paid for
+ // chips they never got. The house's cut is the one thing on this table that is
+ // supposed to go and not come back, so it is counted rather than mourned.
+ //
+ // Percentage with a cap, which is what nearly every room and home game runs. Zero
+ // percent is a game that doesn't rake, which is the default and the common case.
+
+ // What the house takes from a pot of this size.
+ //
+ // "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
+ // flop pays nothing, because the players never got a hand out of it. It is on by
+ // default and can be turned off for a house that rakes every pot.
+ rakeOn(state, pot, sawFlop = true) {
+ const rake = state.rake || {};
+ const percent = Number(rake.percent) || 0;
+ const amount = Money.round(Number(pot) || 0);
+ if (percent <= 0 || amount <= 0) return 0;
+ if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
+
+ const uncapped = (amount * percent) / 100;
+ const cap = Number(rake.cap);
+ const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
+ // Never more than the pot, however the numbers were typed in.
+ return Money.downToChip(Math.min(capped, amount), state.chipLegend);
+ },
+
+ // The same payouts with the house's cut taken out of them.
+ //
+ // Off the top of the whole pot, then shared out in proportion — which for the hand
+ // a dealer actually deals, with one winner, is just "winner gets the pot less the
+ // rake". Split pots take it proportionally, and whatever the rounding leaves over
+ // goes back to the winners in turn rather than to the house: the house asked for a
+ // number, and it gets that number, not that number plus dust.
+ applyRake(payouts, rake) {
+ const total = payouts.reduce((sum, p) => sum + p.amount, 0);
+ const taken = Money.round(Math.min(Math.max(0, rake), total));
+ if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
+
+ const kept = Money.toCents(total) - Money.toCents(taken);
+ const shares = payouts.map(p => {
+ const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
+ return { payout: p, cents: Math.floor(exact) };
+ });
+
+ // Floor everybody, then hand the leftover cents out one at a time, biggest
+ // share first — the same shape as the odd-chip rule the split calculator uses.
+ let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
+ [...shares]
+ .sort((a, b) => b.payout.amount - a.payout.amount)
+ .forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
+
+ return {
+ payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
+ rake: taken,
+ };
+ },
+
bankReconciliation(state) {
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
const chipsInPlay = state.seats.reduce(
@@ -837,11 +918,16 @@ const HoldemState = {
// hunting for a miscount that never happened.
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
- const difference = Money.round(chipsInPlay + discarded - cashIn);
+ // Chips the house has taken. Off the table for good, like the discards, and
+ // for a better reason: the box is supposed to have them.
+ const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
+
+ const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
+ raked,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
diff --git a/tests/holdem/rake.test.js b/tests/holdem/rake.test.js
new file mode 100644
index 0000000..cafda51
--- /dev/null
+++ b/tests/holdem/rake.test.js
@@ -0,0 +1,230 @@
+// The house's cut.
+//
+// A game that rakes has chips leaving the table every hand, and the bank check used to
+// call them missing: buy-ins in, chips on the felt, and a dealer told somebody had been
+// paid for chips they never got. The cut is the one thing here that is supposed to go
+// and not come back.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Money, HoldemState, Betting, makeTable, chipsOnTable } = require("./load");
+
+const raking = (rake, extra = {}) => {
+ const state = makeTable({ seatCount: 6, stack: 200, ...extra });
+ state.rake = { percent: 0, cap: null, noFlopNoDrop: true, ...rake };
+ return state;
+};
+
+describe("what the house takes", () => {
+ test("nothing, unless somebody says otherwise", () => {
+ const state = makeTable({ seatCount: 6 });
+ assert.equal(state.rake.percent, 0);
+ assert.equal(HoldemState.rakeOn(state, 100), 0);
+ });
+
+ test("a percentage of the pot", () => {
+ assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 100), 5);
+ assert.equal(HoldemState.rakeOn(raking({ percent: 10 }), 60), 6);
+ });
+
+ test("no more than the cap", () => {
+ const state = raking({ percent: 5, cap: 5 });
+ assert.equal(HoldemState.rakeOn(state, 100), 5);
+ assert.equal(HoldemState.rakeOn(state, 400), 5, "still five on a pot four times the size");
+ assert.equal(HoldemState.rakeOn(state, 40), 2, "under the cap it is just the percentage");
+ });
+
+ test("no flop, no drop", () => {
+ const state = raking({ percent: 5 });
+ assert.equal(HoldemState.rakeOn(state, 100, false), 0, "hand ended before the flop");
+ assert.equal(HoldemState.rakeOn(state, 100, true), 5);
+ });
+
+ test("unless the house rakes every pot", () => {
+ const state = raking({ percent: 5, noFlopNoDrop: false });
+ assert.equal(HoldemState.rakeOn(state, 100, false), 5);
+ });
+
+ test("nothing it can't make out of the table's chips, rounded the players' way", () => {
+ // 5% of $30 is $1.50, and this table's smallest chip is a dollar.
+ const state = raking({ percent: 5 });
+ assert.equal(HoldemState.rakeOn(state, 30), 1);
+ // 5% of $10 is 50c — under a chip, so the house takes nothing at all.
+ assert.equal(HoldemState.rakeOn(state, 10), 0);
+ });
+
+ test("never more than the pot, whatever gets typed in", () => {
+ assert.equal(HoldemState.rakeOn(raking({ percent: 100 }), 20), 20);
+ assert.equal(HoldemState.rakeOn(raking({ percent: 5, cap: 500 }), 20), 1);
+ });
+
+ test("nothing out of an empty pot", () => {
+ assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 0), 0);
+ });
+});
+
+describe("taking it out of the pot", () => {
+ const winner = amount => [{ id: "a", label: "Ann", amount }];
+
+ test("one winner just gets the pot less the cut", () => {
+ const { payouts, rake } = HoldemState.applyRake(winner(100), 5);
+ assert.equal(rake, 5);
+ assert.deepEqual(payouts.map(p => p.amount), [95]);
+ });
+
+ test("a split pot shares what's left, in proportion", () => {
+ const { payouts } = HoldemState.applyRake(
+ [{ id: "a", label: "Ann", amount: 60 }, { id: "b", label: "Ben", amount: 40 }], 10);
+ assert.deepEqual(payouts.map(p => p.amount), [54, 36]);
+ });
+
+ test("the rounding leftovers go back to the players, not to the house", () => {
+ // $100 split three ways less $7: 93 doesn't divide evenly, and every cent of
+ // it has to land on somebody.
+ const { payouts, rake } = HoldemState.applyRake([
+ { id: "a", label: "Ann", amount: 34 },
+ { id: "b", label: "Ben", amount: 33 },
+ { id: "c", label: "Cal", amount: 33 },
+ ], 7);
+ assert.equal(rake, 7);
+ assert.equal(Money.round(payouts.reduce((sum, p) => sum + p.amount, 0)), 93);
+ });
+
+ test("what goes out plus what the house keeps is what was in the middle", () => {
+ [[100, 5], [37.5, 1.25], [1000, 0], [9, 9]].forEach(([pot, rake]) => {
+ const result = HoldemState.applyRake(winner(pot), rake);
+ assert.equal(Money.round(result.payouts[0].amount + result.rake), pot, `${pot} less ${rake}`);
+ });
+ });
+
+ test("asking for more than the pot takes the pot, not more", () => {
+ const { payouts, rake } = HoldemState.applyRake(winner(20), 50);
+ assert.equal(rake, 20);
+ assert.deepEqual(payouts.map(p => p.amount), [0]);
+ });
+
+ test("no rake, no change — the same payouts come back", () => {
+ const { payouts, rake } = HoldemState.applyRake(winner(80), 0);
+ assert.equal(rake, 0);
+ assert.deepEqual(payouts.map(p => p.amount), [80]);
+ });
+});
+
+describe("what the box is checked against", () => {
+ test("raked chips are gone but not missing", () => {
+ const state = raking({ percent: 5 });
+ state.seats.forEach(p => { p.buyIns = 200; });
+ const before = HoldemState.bankReconciliation(state);
+ assert.equal(before.balanced, true);
+
+ // The house takes $10 out of the middle: somebody's stack is $10 lighter.
+ state.seats[0].stack = 190;
+ state.rakeTaken = 10;
+ const after = HoldemState.bankReconciliation(state);
+ assert.equal(after.raked, 10);
+ assert.equal(after.balanced, true, "the check still balances");
+ });
+
+ test("and are told apart from the ones that went astray", () => {
+ const state = raking({ percent: 5 });
+ state.seats.forEach(p => { p.buyIns = 200; });
+ state.seats[0].stack = 180;
+ state.rakeTaken = 10;
+ const check = HoldemState.bankReconciliation(state);
+ assert.equal(check.raked, 10);
+ assert.equal(check.difference, -10, "ten dollars really are missing");
+ assert.equal(check.balanced, false);
+ });
+
+ test("a table that has never raked reads exactly as it did before", () => {
+ const state = makeTable({ seatCount: 6 });
+ state.seats.forEach(p => { p.buyIns = 100; p.stack = 100; });
+ const check = HoldemState.bankReconciliation(state);
+ assert.equal(check.raked, 0);
+ assert.equal(check.balanced, true);
+ });
+});
+
+describe("the record of it", () => {
+ test("the hand log keeps the pot as the table saw it, and the cut beside it", () => {
+ const state = raking({ percent: 5 });
+ HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 95 }], 5);
+ const [entry] = state.handHistory;
+ assert.equal(entry.pot, 100, "the pot in the middle");
+ assert.equal(entry.rake, 5);
+ assert.equal(entry.payouts[0].amount, 95, "what the winner actually took");
+ });
+
+ test("a hand with no rake records a zero rather than nothing", () => {
+ const state = makeTable({ seatCount: 6 });
+ HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 40 }]);
+ assert.equal(state.handHistory[0].rake, 0);
+ assert.equal(state.handHistory[0].pot, 40);
+ });
+
+ test("a table saved before the house took anything takes nothing", () => {
+ const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
+ delete parsed.rake;
+ delete parsed.rakeTaken;
+ const migrated = HoldemState.migrate(parsed);
+ assert.equal(migrated.rake.percent, 0);
+ assert.equal(migrated.rake.noFlopNoDrop, true);
+ assert.equal(migrated.rakeTaken, 0);
+ });
+
+ test("nonsense in a saved rake is cleaned up rather than trusted", () => {
+ const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
+ parsed.rake = { percent: -5, cap: -1, noFlopNoDrop: false };
+ parsed.rakeTaken = -20;
+ const migrated = HoldemState.migrate(parsed);
+ assert.equal(migrated.rake.percent, 0);
+ assert.equal(migrated.rake.cap, null);
+ assert.equal(migrated.rake.noFlopNoDrop, false, "that one is a real choice");
+ assert.equal(migrated.rakeTaken, 0);
+ });
+});
+
+describe("the file the dealer hands over", () => {
+ const { HandHistoryExport } = require("./load");
+
+ test("has a column for what the house took", () => {
+ const state = raking({ percent: 5 });
+ HoldemState.recordHand(state, [{ id: "a", label: "Ann", amount: 95 }], 5);
+ HoldemState.recordHand(state, [{ id: "b", label: "Ben", amount: 40 }], 0);
+
+ // Oldest hand first, and money as bare numbers so a spreadsheet can total it.
+ const csv = HandHistoryExport.toCsv(state.handHistory);
+ const [header, raked, unraked] = csv.split("\n");
+ assert.match(header, /Pot,House take,Payouts/);
+ assert.match(raked, /,100\.00,5\.00,/, "the raked hand says how much was taken");
+ assert.match(unraked, /,40\.00,0\.00,/, "and the unraked one says nothing was");
+ });
+});
+
+describe("a raked hand, end to end", () => {
+ test("the chips add up: stacks plus the house's cut equal what was bought in", () => {
+ // 5/10 blinds, so six callers make a pot big enough for 5% to be a whole chip.
+ // At 1/2 the same hand makes $12, 5% of which is 60c and rounds away to
+ // nothing — which is the rule working, not a hand worth testing the bank on.
+ const state = raking({ percent: 5, cap: 5 }, { smallBlind: 5, bigBlind: 10 });
+ state.seats.forEach(p => { p.buyIns = 200; });
+ Betting.beginHand(state);
+ // Everyone calls the big blind, then it is checked down.
+ state.seats.forEach((p, i) => { if (p.inHand) Betting.putIn(state, i, Betting.toCall(state, p)); });
+ Betting.sweepToHandTotals(state);
+
+ const pot = state.seats.reduce((sum, p) => sum + (Number(p.amountOverride) || 0), 0);
+ const rake = HoldemState.rakeOn(state, pot, true);
+ assert.equal(pot, 60);
+ assert.equal(rake, 3);
+
+ const { payouts } = HoldemState.applyRake([{ id: state.seats[0].id, label: "P1", amount: pot }], rake);
+ state.rakeTaken = rake;
+ HoldemState.recordHand(state, payouts, rake);
+ Betting.creditWinnings(state, payouts);
+ HoldemState.startNextHand(state);
+
+ assert.equal(Money.round(chipsOnTable(state) + state.rakeTaken), 1200);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+});
From 0e2821257f0d8e50673da8784510b8eb9cad8a09 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 05:40:06 -0600
Subject: [PATCH 47/55] Run the clock on a player, instead of counting in your
head
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The bar has had a stopwatch on the acting seat for a while: how long this player
has been thinking, so the dealer knows when it's worth a nudge. What it never had
was the thing a dealer actually does about it. Calling the clock is a procedure —
a player asks, the dealer grants it, the player gets a set time and then a
countdown said out loud, and if they still haven't acted the hand is decided for
them. Running that from this tool meant counting in your head while the table
argued about whether it had been thirty seconds yet.
Thirty and then ten, which is the common form and what the TDA writes down. The
countdown is the part the table hears, so it is marked apart rather than being
ten more seconds of the same: the readout switches to a bare number and turns
red. At zero the bar says the ruling in the dealer's own words and offers it as
one button.
The ruling depends on what they were facing, and getting that backwards is the
part worth being careful about. Facing a bet, the hand is dead. Facing nothing,
it is checked — nobody gets folded for being slow when checking was free.
Three things about where it sits. The button appears only once somebody has been
thinking long enough for the table to start asking, which is the same threshold
the stopwatch already marks, so an ordinary hand costs no room at all: measured
at 390x844, the bar is 152px with or without it. It is 44px like everything else
a thumb has to find — it was drawn at 36 and the audit called it at every touch
viewport the moment the check ran, which is the rule doing its job on a control
that kills a hand. And once the count is over the Stop button goes, because there
is nothing left to stop: at 640x360 its 94px pushed the undo control onto a
second line and the taller bar covered the two bottom seats.
The ordinary Fold/Check/Raise stay underneath the ruling. A player who speaks up
as the count ends is still acting, and whether it was in time is the dealer's
call, not the tool's.
11 new tests, and both halves of the ruling were inverted on purpose to check
they catch it. Watched end to end in the browser at shortened intervals: the
button appears, the count runs down, the ruling lands, and the hand is killed —
or checked — with the undo control naming what happened.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 17 ++
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 48 ++++++
.../wwwroot/js/casino/holdem/actionBar.js | 163 +++++++++++++++++-
tests/holdem/callingTheClock.test.js | 129 ++++++++++++++
4 files changed, 356 insertions(+), 1 deletion(-)
create mode 100644 tests/holdem/callingTheClock.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 558d334..5db9c99 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -391,6 +391,23 @@
+
+
Calling the clock on a player
+
+ Any player may ask for a clock once a decision is taking unreasonably
+ long; it is the dealer, not the table, who grants it. The player then
+ gets a set time and a countdown out loud — thirty seconds and ten is the
+ usual form.
+
+
+ If the count reaches zero with no action, the hand is dead if
+ they were facing a bet, and checked if they were not: nobody is
+ folded for slowness when checking was free. The action bar runs the
+ count once you tap Call the clock, and offers the ruling as one
+ button when it ends.
+
+
+
A straddle is a blind raise
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index b2f67c2..0f0df21 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -2254,6 +2254,46 @@ body.hasActionBar #holdemSidebar {
font-weight: 600;
}
+/* Starting and stopping a clock. Quiet, because it only appears once somebody has
+ been thinking long enough for the table to ask.
+
+ 44px like everything else a thumb has to find. It was drawn at 36 to keep the status
+ line short, and the audit called it at every touch viewport the moment it ran — which
+ is the rule doing its job: this control kills a hand, and the argument for shaving it
+ was that the row it sits in is made of text. */
+.actionClockBtn {
+ flex: none;
+ min-height: 44px;
+ padding: 0.25rem 0.6rem;
+ border-radius: 8px;
+ border: 1px solid #5b4a2a;
+ background: rgba(216, 180, 106, 0.12);
+ color: #e8cf9a;
+ font-family: inherit;
+ font-size: 0.72rem;
+ cursor: pointer;
+}
+
+/* The count has run out. Said in the dealer's own words, above the button that
+ carries it out — and coloured like the thing it is, which is a hand about to be
+ killed for slow play. */
+.clockExpired {
+ margin: 0 0 0.5rem;
+ padding: 0.45rem 0.6rem;
+ border-radius: 8px;
+ background: rgba(224, 123, 123, 0.12);
+ border: 1px solid rgba(224, 123, 123, 0.45);
+ color: #f3c9c9;
+ font-size: 0.8rem;
+}
+
+.actionBtn.actionEnforceClock {
+ background: linear-gradient(180deg, #6b2b2b, #4a1d1d);
+ border-color: #8c3a3a;
+ color: #ffe2e2;
+ font-weight: 600;
+}
+
/* ---------- Side-pot breakdown ---------- */
.potBreakdown {
@@ -3431,6 +3471,14 @@ body.hasActionBar #toastHost {
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
}
+ /* The clock's ruling, said in a sentence, is a line this screen cannot pay for:
+ measured at 640x360, the bar goes from 152px to 269px when the count runs out and
+ the bottom seats end up behind it. The button carries the same ruling in three
+ words — "Kill Dee's hand" — so the sentence is what goes, not the call. */
+ .clockExpired {
+ display: none;
+ }
+
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
.seat {
width: 62px;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 93c57d7..b0bc889 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -194,6 +194,32 @@ const ActionBar = {
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
row.appendChild(tank);
+
+ // Rendered every time and shown by the tick once the wait is long enough
+ // to be worth it, so a clock never costs a re-render at the moment the
+ // dealer reaches for it — and never occupies the bar in the ordinary case
+ // where nobody is holding anybody up.
+ const clock = document.createElement("button");
+ clock.type = "button";
+ clock.id = "actionClockBtn";
+ clock.className = "actionClockBtn";
+ // Once the count is over there is nothing to stop: the ruling has been
+ // made, and the dealer either carries it out or takes an ordinary action,
+ // both of which end the clock themselves. Leaving the button there also
+ // cost real room — at 640x360 its 94px pushed the undo control onto a
+ // second line, and the taller bar covered the two bottom seats.
+ const expiredNow = this.clockRunning() && this.clockLeft() === 0;
+ clock.hidden = !this.clockRunning() || expiredNow;
+ if (this.clockRunning()) {
+ clock.textContent = "Stop the clock";
+ clock.title = "Put the decision back in the player's own time";
+ clock.addEventListener("click", () => { this.stopClock(); this.render(); });
+ } else {
+ clock.textContent = "Call the clock";
+ clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`;
+ clock.addEventListener("click", () => this.callClock());
+ }
+ row.appendChild(clock);
}
// A standing control rather than a toast per action: a dealer taps this bar
@@ -233,6 +259,74 @@ const ActionBar = {
NUDGE_AFTER_S: 30,
SLOW_AFTER_S: 60,
+ // ---- Calling the clock ----
+ //
+ // The stopwatch above says how long somebody has been thinking. This is what a
+ // dealer does about it, and it is a procedure with a shape: a player asks for a
+ // clock, the dealer grants it, the player gets a set time and then a countdown,
+ // and if they still haven't acted the hand is decided for them — dead if they were
+ // facing a bet, checked if they weren't.
+ //
+ // The tool knew none of that. A dealer running it had to count in their head while
+ // the table argued about whether it had been thirty seconds, which is the moment
+ // the count needs to be somewhere everybody can see.
+ //
+ // Thirty seconds and then a ten-second countdown is the common form of the rule and
+ // what the TDA writes down. The countdown is the part the table hears out loud, so
+ // it is marked separately rather than being ten more seconds of the same.
+ CLOCK_THINK_S: 30,
+ CLOCK_COUNTDOWN_S: 10,
+
+ clockSeatIndex: null,
+ clockStartedAt: null,
+
+ clockRunning() {
+ return this.clockSeatIndex !== null &&
+ this.clockSeatIndex === this.state.actingSeatIndex &&
+ this.clockStartedAt !== null;
+ },
+
+ // Seconds left before the hand is decided, or null when no clock is running.
+ // Negative is impossible: it stops at zero and stays there until the dealer acts.
+ clockLeft(now = Date.now()) {
+ if (!this.clockRunning()) return null;
+ const total = this.CLOCK_THINK_S + this.CLOCK_COUNTDOWN_S;
+ return Math.max(0, total - Math.floor((now - this.clockStartedAt) / 1000));
+ },
+
+ // What happens when it reaches zero, in the words the dealer says out loud.
+ // Facing a bet, the hand is dead. Facing none, it is checked — a player who could
+ // have checked for free is not folded for being slow.
+ clockRuling() {
+ const index = this.state.actingSeatIndex;
+ if (index === null || index === undefined) return null;
+ const player = this.state.seats[index];
+ const owed = Betting.toCall(this.state, player);
+ const who = HoldemState.displayName(player, index);
+ return owed > 0
+ ? { index, who, verb: "fold", label: `Time — ${who}'s hand is dead`, say: `${who} is facing ${Money.format(owed)}, so the hand is dead.` }
+ : { index, who, verb: "check", label: `Time — check for ${who}`, say: `${who} had nothing to call, so the hand is checked.` };
+ },
+
+ callClock() {
+ const index = this.state.actingSeatIndex;
+ if (index === null || index === undefined) return;
+ this.clockSeatIndex = index;
+ this.clockStartedAt = Date.now();
+ this.clockExpiredShown = false;
+ this.render();
+ },
+
+ stopClock() {
+ this.clockSeatIndex = null;
+ this.clockStartedAt = null;
+ this.clockExpiredShown = false;
+ },
+
+ // Whether the bar has already been rebuilt into its "time is up" shape, so the
+ // one-second tick doesn't rebuild it again every second afterwards.
+ clockExpiredShown: false,
+
tankSeatIndex: null,
tankStartedAt: null,
tankInterval: null,
@@ -262,6 +356,11 @@ const ActionBar = {
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
+ // A clock belongs to one player's decision. The moment the action moves —
+ // because they acted, or because the dealer took something back — it is
+ // over, and leaving it running would put the next player on a countdown
+ // nobody called on them.
+ this.stopClock();
}
if (!this.tankInterval) {
@@ -283,6 +382,36 @@ const ActionBar = {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
+ // A clock takes the readout over: the number the table is waiting on is no
+ // longer how long this has taken but how long is left. At zero the bar has to
+ // change shape — a ruling and the button that carries it out — so that one
+ // gets a full render, once, rather than being painted in by the tick.
+ const left = this.clockLeft();
+ if (left !== null) {
+ if (left === 0 && !this.clockExpiredShown) {
+ this.clockExpiredShown = true;
+ this.render();
+ return;
+ }
+ el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
+ el.title = "Time left before the hand is decided";
+ el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
+ el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
+ return;
+ }
+
+ // The button that starts a clock is offered once somebody has been thinking
+ // long enough for the table to start asking — which is the same threshold the
+ // stopwatch already marks. Before that it is a control nobody needs, and this
+ // bar is docked over the seats.
+ const nudge = document.getElementById("actionClockBtn");
+ if (nudge && !this.clockRunning()) {
+ const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
+ nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
+ ? false
+ : waited < this.NUDGE_AFTER_S;
+ }
+
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—";
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
@@ -405,6 +534,36 @@ const ActionBar = {
const stack = Betting.stackOf(this.state, player);
const who = HoldemState.displayName(player, index);
+ // Time is up. The ruling goes on the bar in the words the dealer says out loud,
+ // with the one action that carries it out — and the ordinary buttons stay
+ // underneath, because a player who speaks up as the count ends is still acting
+ // and the dealer is the one who decides whether it was in time.
+ const expired = this.clockLeft() === 0 ? this.clockRuling() : null;
+ const wrap = document.createElement("div");
+ if (expired) {
+ const line = document.createElement("p");
+ line.className = "clockExpired";
+ // The ruling in one sentence. `label` is the same call in three words for
+ // the button beside it; saying both left the line doubling back on itself
+ // — "Time — Dee's hand is dead — Dee is facing $2, so the hand is dead."
+ line.textContent = `Time — ${expired.say}`;
+ wrap.appendChild(line);
+
+ const enforce = document.createElement("div");
+ enforce.className = "actionButtons";
+ enforce.appendChild(this.button(
+ expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
+ "actionEnforceClock",
+ () => this.commit(
+ expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`,
+ () => {
+ this.stopClock();
+ if (expired.verb === "fold") Betting.fold(this.state, index);
+ else Betting.check(this.state, index);
+ })));
+ wrap.appendChild(enforce);
+ }
+
const row = document.createElement("div");
row.className = "actionButtons";
@@ -431,7 +590,9 @@ const ActionBar = {
}));
}
- return row;
+ if (!expired) return row;
+ wrap.appendChild(row);
+ return wrap;
},
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
diff --git a/tests/holdem/callingTheClock.test.js b/tests/holdem/callingTheClock.test.js
new file mode 100644
index 0000000..8e34fbe
--- /dev/null
+++ b/tests/holdem/callingTheClock.test.js
@@ -0,0 +1,129 @@
+// Calling the clock on a player.
+//
+// The bar already had a stopwatch saying how long somebody had been thinking. This is
+// what a dealer does about it: a procedure with a shape — a set time, a countdown said
+// out loud, and a ruling at the end that depends on what the player was facing.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { ActionBar, Betting, makeTable } = require("./load");
+
+// The bar rebuilds the DOM when a clock starts; none of the rules below need it.
+const onTable = (state) => {
+ ActionBar.state = state;
+ ActionBar.render = () => {};
+ ActionBar.stopClock();
+ return ActionBar;
+};
+
+const dealt = () => {
+ const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
+ Betting.beginHand(state);
+ return state;
+};
+
+describe("starting one", () => {
+ test("it runs on the player who is actually on the clock", () => {
+ const state = dealt();
+ const bar = onTable(state);
+ assert.equal(bar.clockRunning(), false, "nothing runs until it is called");
+ bar.callClock();
+ assert.equal(bar.clockRunning(), true);
+ assert.equal(bar.clockSeatIndex, state.actingSeatIndex);
+ });
+
+ test("with nobody to act there is nothing to call", () => {
+ const state = dealt();
+ state.actingSeatIndex = null;
+ const bar = onTable(state);
+ bar.callClock();
+ assert.equal(bar.clockRunning(), false);
+ assert.equal(bar.clockLeft(), null);
+ });
+
+ test("the dealer can put it away again", () => {
+ const bar = onTable(dealt());
+ bar.callClock();
+ bar.stopClock();
+ assert.equal(bar.clockRunning(), false);
+ assert.equal(bar.clockLeft(), null);
+ });
+});
+
+describe("the count", () => {
+ const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000);
+
+ test("is the player's own time and then a countdown", () => {
+ const bar = onTable(dealt());
+ bar.callClock();
+ assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S);
+ assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts");
+ assert.equal(at(bar, 35), 5);
+ });
+
+ test("stops at zero rather than going negative", () => {
+ const bar = onTable(dealt());
+ bar.callClock();
+ assert.equal(at(bar, 40), 0);
+ assert.equal(at(bar, 600), 0, "ten minutes later it is still just up");
+ });
+});
+
+describe("what happens at zero", () => {
+ test("facing a bet, the hand is dead", () => {
+ const state = dealt();
+ const bar = onTable(state);
+ // Whoever is up pre-flop is facing the big blind.
+ const ruling = bar.clockRuling();
+ assert.equal(ruling.verb, "fold");
+ assert.match(ruling.label, /hand is dead/);
+ assert.match(ruling.say, /facing \$2/);
+ });
+
+ test("facing nothing, it is checked — slowness doesn't fold a free hand", () => {
+ const state = dealt();
+ // Everyone calls round to the big blind, who has nothing to call.
+ [3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat));
+ const bar = onTable(state);
+ assert.equal(state.actingSeatIndex, 2, "the big blind is up");
+ const ruling = bar.clockRuling();
+ assert.equal(ruling.verb, "check");
+ assert.match(ruling.label, /check for/i);
+ });
+
+ test("the ruling names the player, because the dealer has to say it out loud", () => {
+ const state = dealt();
+ state.seats[state.actingSeatIndex].name = "Priya";
+ const ruling = onTable(state).clockRuling();
+ assert.match(ruling.label, /Priya/);
+ assert.match(ruling.say, /Priya/);
+ });
+
+ test("with nobody to act there is no ruling to make", () => {
+ const state = dealt();
+ state.actingSeatIndex = null;
+ assert.equal(onTable(state).clockRuling(), null);
+ });
+});
+
+describe("what ends it", () => {
+ test("the player acting", () => {
+ const state = dealt();
+ const bar = onTable(state);
+ bar.callClock();
+ const wasOn = state.actingSeatIndex;
+ Betting.call(state, wasOn);
+ assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved");
+ assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's");
+ });
+
+ test("and it doesn't follow the action to the next player", () => {
+ // A clock is called on one decision. Left running it would put a countdown on
+ // somebody nobody asked about.
+ const state = dealt();
+ const bar = onTable(state);
+ bar.callClock();
+ Betting.call(state, state.actingSeatIndex);
+ assert.equal(bar.clockLeft(), null);
+ });
+});
From f966d8c340c6dccae4daf698d7dca9439885fc5e Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 08:08:46 -0600
Subject: [PATCH 48/55] Pay somebody out and keep the chair, the cash and the
record straight
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The most ordinary thing that happens at a home game after midnight — somebody
counts up and goes home — and the tool had nothing for it. Freeing the chair
meant wiping the seat by hand, and the bank check then declared their whole stack
missing: "someone has paid for chips they haven't been given", for the rest of
the night. Measured on a balanced table, cashing a player out with $350 in front
of them left the check $350 short. Wiping their buy-in as well silenced it, at
the price of losing their entire night from the standings and from the file the
dealer hands over at the end.
Cashing out is two facts, and both have to survive the empty chair: the box took
their cash, and the box handed their chips back. Both are kept now, so the chair
opens and the money still adds up.
Cash out… is in the seat menu. It says what to count — "$350 — 3×$100, 2×$25" —
what they bought in for, and whether they leave up or down, because that is the
sentence the dealer says while their hands are moving. The bank check gains a
Paid out line and reads "balanced ... once the $350 paid out to players who have
left is counted", alongside the discards and the house's cut. The standings keep
them with a stripe and the words "· paid out", so nobody counts money out to them
twice at 2am, and the export marks them the same way.
It refuses to pay anybody with money still in the middle: taking chips out of a
pot other people can win, or handing the same chips over twice, are both worse
than waiting for the hand to finish. The chair gets a fresh id, because the
person who had it has gone and the next player to sit down is not them.
Two things this turned up. The first draft worked out what a seat was holding by
itself, read `Number(null)` as a perfectly good zero, and offered to pay a player
with no stack set nothing at all — it asks Betting now, which owns that rule.
The second was mine and the audit caught it: marking the departed row with
opacity took the In and Won cells to 3.83:1, under AA, on the very list a dealer
settles from. It is a stripe instead.
19 new tests, with the departed buy-in dropped from the check and the pot guard
disabled on purpose to confirm they fail. Walked through in the browser: menu,
confirm, payout, bank, standings, undo, and the refusal.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 4 +
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 18 ++
.../js/casino/holdem/handHistoryExport.js | 5 +-
.../wwwroot/js/casino/holdem/main.js | 33 +++
.../wwwroot/js/casino/holdem/render.js | 21 +-
.../wwwroot/js/casino/holdem/seatMenu.js | 7 +
.../wwwroot/js/casino/holdem/state.js | 133 +++++++++-
tests/holdem/cashOut.test.js | 229 ++++++++++++++++++
8 files changed, 444 insertions(+), 6 deletions(-)
create mode 100644 tests/holdem/cashOut.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 5db9c99..40dbd8c 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -162,6 +162,10 @@
House take
+
+ Paid out
+
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 0f0df21..ebabf22 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -2294,6 +2294,24 @@ body.hasActionBar #holdemSidebar {
font-weight: 600;
}
+/* A player who has taken their chips and gone. Still on the list, because the box took
+ their cash and the night's record has to say so, but set apart so nobody counts money
+ out to them twice at 2am.
+
+ Set apart by a stripe rather than by fading the row, which is what this first did:
+ at 0.72 the contrast check measured the In and Won cells at 3.83:1, under AA, on the
+ list a dealer settles the night from. The row says "paid out" in words next to the
+ name — it doesn't need to be hard to read as well. */
+.standingsRow.standingsGone {
+ background: rgba(255, 255, 255, 0.04);
+ border-left: 2px solid #4a4a4a;
+ padding-left: 0.35rem;
+}
+
+.standingsRow.standingsGone .standingsName {
+ font-style: italic;
+}
+
/* ---------- Side-pot breakdown ---------- */
.potBreakdown {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
index 1d0bf38..582f8ba 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
@@ -54,7 +54,10 @@ const HandHistoryExport = {
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
standings.forEach(row => {
rows.push([
- row.name,
+ // Somebody who has gone home is marked in the file the same way the
+ // panel marks them, because this is the copy that gets read at the
+ // end of the night when nobody remembers who has already been paid.
+ row.cashedOut ? `${row.name} (paid out)` : row.name,
Money.forSheet(row.in),
Money.forSheet(row.won),
Money.forSheet(row.net),
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 327cc9a..8f48301 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -96,6 +96,39 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
+ // Somebody is done for the night. The dealer counts their chips out, the chair
+ // opens, and both halves of what happened stay on the record — the cash the box
+ // took from them, and the chips it handed back. Wiping the seat by hand did
+ // neither: it left the bank check declaring their whole stack missing for the
+ // rest of the night.
+ cashOut: async index => {
+ const allowed = HoldemState.canCashOut(state, index);
+ if (!allowed.ok) {
+ Toast.show(allowed.reason, { duration: 4000 });
+ return;
+ }
+
+ const preview = HoldemState.cashOutPreview(state, index);
+ const result = preview.net === 0
+ ? "even for the night"
+ : preview.net > 0
+ ? `up ${Money.format(preview.net)}`
+ : `down ${Money.format(Math.abs(preview.net))}`;
+ const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
+
+ const ok = await Dialog.confirm({
+ title: `Cash out ${preview.name}?`,
+ message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
+ `${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
+ `night's record keeps both their buy-in and this payout so the bank still balances.`,
+ confirmLabel: `Pay ${Money.format(preview.chips)}`,
+ });
+ if (!ok) return;
+
+ runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
+ () => HoldemState.cashOut(state, index));
+ },
+
// Two players change chairs. Undoable like every other seat action, because a
// swap is easy to aim wrong and the seats look alike at a glance.
swapSeats: (from, to) => {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 19bf744..f9c38f5 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -1012,7 +1012,14 @@ const Render = {
const template = document.getElementById("standingsRowTemplate");
standings.forEach(row => {
const node = template.content.firstElementChild.cloneNode(true);
- node.querySelector(".standingsName").textContent = row.name;
+ // Somebody who has gone home is still on the list — they played, and the
+ // box took their cash — but the number beside them is money already handed
+ // over, not chips waiting to be counted. Marked, or a dealer settling up at
+ // 2am pays them twice.
+ node.classList.toggle("standingsGone", !!row.cashedOut);
+ node.querySelector(".standingsName").textContent = row.cashedOut
+ ? `${row.name} · paid out`
+ : row.name;
node.querySelector(".standingsIn").textContent = Money.format(row.in);
node.querySelector(".standingsWon").textContent = Money.format(row.won);
@@ -1169,11 +1176,23 @@ const Render = {
}
}
+ // Chips handed back to people who have gone home. The third way money leaves
+ // this table on purpose, and the one a dealer is asked about most: "where did
+ // Ann's four hundred go?"
+ const paidRow = document.getElementById("bankPaidOutRow");
+ if (paidRow) {
+ paidRow.hidden = !(bank.paidOut > 0);
+ if (bank.paidOut > 0) {
+ document.getElementById("bankPaidOut").textContent = Money.format(bank.paidOut);
+ }
+ }
+
const verdict = document.getElementById("bankReconcileVerdict");
// What has left the table on purpose, whichever way it went.
const accountedFor = [
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
+ bank.paidOut > 0 ? `${Money.format(bank.paidOut)} paid out to players who have left` : null,
].filter(Boolean);
if (bank.balanced) {
verdict.textContent = accountedFor.length > 0
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
index 6feb934..a90c5b2 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
@@ -47,6 +47,13 @@ const SeatMenu = {
run: () => this.hooks.toggleDealtIn(index),
});
+ // Leaving for the night, which is a different thing from sitting out a hand:
+ // the chips go back over the rail and the chair opens. Offered only for a seat
+ // that has somebody in it, since an empty chair has nobody to pay.
+ if ((player.name || "").trim() || Number(player.buyIns) > 0 || Number(player.stack) > 0) {
+ actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
+ }
+
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 34cfa20..c8ae787 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -110,6 +110,11 @@ const HoldemState = {
// What it has taken so far tonight. A session total, not a per-hand one:
// it is the number the box is checked against at the end.
rakeTaken: 0,
+ // People who have taken their chips and gone. Kept for the night rather
+ // than forgotten with the seat: the box took their cash and paid it back
+ // out, and both halves have to stay on the record or the bank check reads
+ // as a shortfall for the rest of the evening.
+ cashedOut: [],
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
@@ -228,6 +233,17 @@ const HoldemState = {
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
+ parsed.cashedOut = Array.isArray(parsed.cashedOut)
+ ? parsed.cashedOut
+ .filter(row => row && row.id)
+ .map(row => ({
+ id: row.id,
+ name: String(row.name || "").slice(0, 40),
+ buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)),
+ chips: Math.max(0, Money.round(Number(row.chips) || 0)),
+ at: Number(row.at) || 0,
+ }))
+ : [];
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
@@ -753,6 +769,15 @@ const HoldemState = {
// Cash bought in and chips currently in front don't come from the hand log at
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
const seatById = new Map(state.seats.map(p => [p.id, p]));
+ // And for anyone who has gone home, off the record their cash-out left behind.
+ // Without this they read as a player who bought in for nothing and is holding
+ // nothing, in the very list the dealer settles the night from.
+ const goneById = new Map((state.cashedOut || []).map(row => [row.id, row]));
+
+ goneById.forEach((row, id) => {
+ if (totals.has(id)) return;
+ totals.set(id, { id, name: row.name, in: 0, won: 0 });
+ });
// Anyone who has money in the game belongs in the settle-up, whether or not
// they've been in a logged hand yet. Building the list from the hand log alone
@@ -774,8 +799,16 @@ const HoldemState = {
return Array.from(totals.values())
.map(t => {
const seat = seatById.get(t.id);
- const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
- const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
+ const gone = goneById.get(t.id);
+ const boughtIn = seat
+ ? Money.round(Number(seat.buyIns) || 0)
+ : (gone ? gone.buyIns : 0);
+ // What a departed player is "holding" is what the box handed them on
+ // the way out. It is no longer on the table, so it is marked as paid
+ // rather than counted again at the end of the night.
+ const chipsNow = seat
+ ? Money.round(Number(seat.stack) || 0)
+ : (gone ? gone.chips : 0);
// Chips already pushed into the hand in progress are still this
// player's money until it's won. Leaving them out makes the cash
// position disagree with the hand log for as long as a hand is open —
@@ -806,6 +839,10 @@ const HoldemState = {
// a $340 stack read as "$140" at the exact moment cash changed
// hands.
chipsInFront: Money.round(chipsNow + inFrontOfThem),
+ // Set once they have taken their chips and left, so the panel and
+ // the export can say so rather than showing a player still sitting
+ // there with a stack.
+ cashedOut: !!gone && !seat,
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
notes,
};
@@ -838,6 +875,84 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
+ // ---- Somebody leaving with their chips ----
+ //
+ // The most ordinary thing that happens at a home game after midnight, and the tool
+ // had nothing for it. A dealer's only way to free the chair was to wipe the seat by
+ // hand, and then the bank check — which adds up cash taken against chips on the
+ // table — declared the departing player's whole stack missing: "someone has paid
+ // for chips they haven't been given", for the rest of the night. Wiping their
+ // buy-in as well silenced it, at the price of losing their night from the standings
+ // and the file the dealer hands over.
+ //
+ // Cashing out is two facts, and both have to survive the empty chair: the box took
+ // their cash, and the box paid their chips back out.
+
+ // Can this seat be cashed out right now?
+ canCashOut(state, index) {
+ const player = state.seats[index];
+ if (!player) return { ok: false, reason: "There's nobody in that seat." };
+ const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0));
+ if (inPot > 0) {
+ return {
+ ok: false,
+ // Paying someone out with money still in the middle would either take
+ // chips out of a pot other people can win, or hand them chips twice.
+ reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`,
+ };
+ }
+ return { ok: true };
+ },
+
+ // What the dealer counts out, and what the seat's night comes to.
+ cashOutPreview(state, index) {
+ const player = state.seats[index];
+ if (!player) return null;
+ // Betting owns the answer to "what is this seat holding" — including the rule
+ // that a seat which has never been given a stack is holding the table's
+ // starting one. Asking it rather than working it out again here: the first
+ // draft of this did, read `Number(null)` as a perfectly good zero, and offered
+ // to pay a player nothing.
+ const chips = Money.round(Math.max(0, Betting.stackOf(state, player)));
+ const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0));
+ return {
+ index,
+ name: this.displayName(player, index),
+ chips,
+ boughtIn,
+ // Their result for the night, in the direction a banker says it: positive
+ // means the box is paying out more than it took from them.
+ net: Money.round(chips - boughtIn),
+ breakdown: Money.formatChipBreakdown(chips, state.chipLegend),
+ };
+ },
+
+ // Hand them their chips and free the chair, keeping both halves on the record.
+ cashOut(state, index) {
+ const allowed = this.canCashOut(state, index);
+ if (!allowed.ok) return allowed;
+
+ const preview = this.cashOutPreview(state, index);
+ const leavingId = state.seats[index].id;
+ if (!Array.isArray(state.cashedOut)) state.cashedOut = [];
+ state.cashedOut.push({
+ id: leavingId,
+ name: preview.name,
+ buyIns: preview.boughtIn,
+ chips: preview.chips,
+ at: Date.now(),
+ });
+
+ // The chair opens for the next player. A fresh id, because the person who had
+ // it has gone and their night is now filed under the old one — reusing it would
+ // merge two people's results the moment somebody new sits down.
+ const seat = this.makePlayer();
+ state.seats[index] = seat;
+ // Anyone owed a hand by this seat is no longer owed one.
+ state.postingIn = (state.postingIn || []).filter(id => id !== leavingId);
+ return { ok: true, ...preview };
+ },
+
// ---- The house's cut ----
//
// A game that takes a rake or a time charge has chips leaving the table every hand,
@@ -900,7 +1015,12 @@ const HoldemState = {
},
bankReconciliation(state) {
- const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
+ const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
+ // Cash the box took from people who have since left counts as taken: it is in
+ // the box, and forgetting it with the seat is what used to make a cash-out read
+ // as a shortfall all night.
+ const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) +
+ gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0);
const chipsInPlay = state.seats.reduce(
(sum, p) =>
sum +
@@ -922,12 +1042,17 @@ const HoldemState = {
// for a better reason: the box is supposed to have them.
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
- const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
+ // Chips handed back over the rail. Off the table like the other two, and the
+ // one the dealer can point at when somebody asks where the money went.
+ const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0));
+
+ const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
raked,
+ paidOut,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
diff --git a/tests/holdem/cashOut.test.js b/tests/holdem/cashOut.test.js
new file mode 100644
index 0000000..56aa2b4
--- /dev/null
+++ b/tests/holdem/cashOut.test.js
@@ -0,0 +1,229 @@
+// Somebody leaving with their chips.
+//
+// The most ordinary thing that happens at a home game after midnight, and the tool had
+// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
+// check — cash taken against chips on the table — declared the departing player's whole
+// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
+// price of losing their night from the standings and the file the dealer hands over.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Money, HoldemState, Betting, makeTable } = require("./load");
+
+const table = () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
+ ["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].buyIns = 200;
+ });
+ return state;
+};
+
+describe("counting them out", () => {
+ test("the preview is what the dealer's hands follow", () => {
+ const state = table();
+ state.seats[0].stack = 350;
+ const preview = HoldemState.cashOutPreview(state, 0);
+ assert.equal(preview.name, "Ann");
+ assert.equal(preview.chips, 350, "what they are holding");
+ assert.equal(preview.boughtIn, 200);
+ assert.equal(preview.net, 150, "up for the night");
+ assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
+ });
+
+ test("a player who is down reads as down, not as a smaller stack", () => {
+ const state = table();
+ state.seats[1].stack = 60;
+ assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
+ });
+
+ test("a seat that never set a stack is holding the table's starting stack", () => {
+ const state = table();
+ state.defaultStack = 200;
+ state.seats[2].stack = null;
+ assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
+ });
+});
+
+describe("what it refuses", () => {
+ test("paying somebody with money still in the middle", () => {
+ const state = table();
+ Betting.beginHand(state);
+ const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
+ const refusal = HoldemState.canCashOut(state, bb);
+ assert.equal(refusal.ok, false);
+ assert.match(refusal.reason, /in the pot/);
+ // And the refusal is the whole story: nothing moved.
+ assert.equal(HoldemState.cashOut(state, bb).ok, false);
+ assert.equal((state.cashedOut || []).length, 0);
+ assert.ok(state.seats[bb].name, "the seat is untouched");
+ });
+
+ test("an empty chair, which has nobody to pay", () => {
+ const state = table();
+ const refusal = HoldemState.canCashOut(state, 99);
+ assert.equal(refusal.ok, false);
+ });
+});
+
+describe("the chair afterwards", () => {
+ test("is empty and ready for the next player", () => {
+ const state = table();
+ state.seats[0].stack = 350;
+ HoldemState.cashOut(state, 0);
+ const seat = state.seats[0];
+ assert.equal(seat.name, "");
+ assert.equal(seat.stack, null);
+ assert.equal(seat.buyIns, 0);
+ assert.equal(seat.inHand, false);
+ });
+
+ test("belongs to somebody new, so two people's nights can't merge", () => {
+ const state = table();
+ const wasId = state.seats[0].id;
+ HoldemState.cashOut(state, 0);
+ assert.notEqual(state.seats[0].id, wasId);
+ });
+
+ test("and isn't owed the blind the person before them was posting", () => {
+ const state = table();
+ state.postingIn = [state.seats[0].id, state.seats[1].id];
+ HoldemState.cashOut(state, 0);
+ assert.deepEqual(state.postingIn, [state.seats[1].id]);
+ });
+});
+
+describe("the bank check", () => {
+ test("balanced before, balanced after — the chips went somewhere known", () => {
+ const state = table();
+ state.seats[0].stack = 350;
+ state.seats[1].stack = 50;
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+
+ HoldemState.cashOut(state, 0);
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.paidOut, 350);
+ assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
+ assert.equal(bank.chipsInPlay, 450);
+ assert.equal(bank.balanced, true);
+ });
+
+ test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
+ const state = table();
+ state.seats[0].stack = 350;
+ state.seats[1].stack = 50; // so the table starts out balanced
+ // What a dealer had to do before: empty the chair themselves.
+ state.seats[0].name = "";
+ state.seats[0].stack = null;
+ state.seats[0].inHand = false;
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.balanced, false);
+ assert.equal(bank.difference, -350, "the tool called it missing money");
+ });
+
+ test("chips that really are missing still show as missing", () => {
+ // Counting a cash-out must not become a way of swallowing a genuine shortfall.
+ const state = table();
+ state.seats[0].stack = 350;
+ state.seats[1].stack = 50; // balanced to start with
+ HoldemState.cashOut(state, 0);
+ state.seats[2].stack = 150; // and now fifty chips have gone astray
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.balanced, false);
+ assert.equal(bank.difference, -50);
+ });
+
+ test("a rake and a cash-out are counted apart", () => {
+ const state = table();
+ state.seats[0].stack = 350;
+ state.rakeTaken = 12;
+ state.seats[1].stack = 38; // the rake came out of Ben's stack
+ HoldemState.cashOut(state, 0);
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.raked, 12);
+ assert.equal(bank.paidOut, 350);
+ assert.equal(bank.balanced, true);
+ });
+});
+
+describe("the record of their night", () => {
+ test("they stay in the standings with what they bought and what they left with", () => {
+ const state = table();
+ state.seats[0].stack = 350;
+ HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
+ HoldemState.cashOut(state, 0);
+
+ const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
+ assert.ok(ann, "still on the list");
+ assert.equal(ann.boughtIn, 200);
+ assert.equal(ann.chipsInFront, 350, "what the box handed over");
+ assert.equal(ann.cashPosition, 150);
+ assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
+ });
+
+ test("somebody still sitting there is not marked", () => {
+ const state = table();
+ HoldemState.cashOut(state, 0);
+ const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
+ assert.equal(ben.cashedOut, false);
+ });
+
+ test("two people leaving are both kept", () => {
+ const state = table();
+ state.seats[0].stack = 350;
+ state.seats[1].stack = 50;
+ HoldemState.cashOut(state, 0);
+ HoldemState.cashOut(state, 1);
+ assert.equal(state.cashedOut.length, 2);
+ assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+
+ test("a table saved before any of this existed has nobody cashed out", () => {
+ const parsed = JSON.parse(JSON.stringify(table()));
+ delete parsed.cashedOut;
+ const migrated = HoldemState.migrate(parsed);
+ assert.deepEqual(migrated.cashedOut, []);
+ assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
+ });
+
+ test("nonsense in a saved record is cleaned up rather than trusted", () => {
+ const parsed = JSON.parse(JSON.stringify(table()));
+ parsed.cashedOut = [
+ { id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
+ { name: "no id" },
+ null,
+ ];
+ const migrated = HoldemState.migrate(parsed);
+ assert.equal(migrated.cashedOut.length, 1);
+ assert.equal(migrated.cashedOut[0].buyIns, 0);
+ assert.equal(migrated.cashedOut[0].chips, 350);
+ assert.equal(migrated.cashedOut[0].at, 0);
+ });
+});
+
+describe("the table carries on", () => {
+ test("the next hand deals round the empty chair", () => {
+ const state = table();
+ state.seats[0].stack = 350;
+ HoldemState.cashOut(state, 0);
+ HoldemState.startNextHand(state);
+ Betting.beginHand(state);
+
+ assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
+ assert.equal(Betting.liveSeats(state).length, 3);
+ assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
+ });
+
+ test("and the money still adds up once it has", () => {
+ const state = table();
+ state.seats[0].stack = 350;
+ state.seats[1].stack = 50;
+ HoldemState.cashOut(state, 0);
+ HoldemState.startNextHand(state);
+ Betting.beginHand(state);
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
+ assert.equal(bank.balanced, true);
+ });
+});
From 86886ac126bb3473256b74ee4b0fd664e8f6af37 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 08:23:27 -0600
Subject: [PATCH 49/55] Take a hand back, which the rulings sheet has been
asking for all along
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This tool has told dealers to redeal on a misdeal since the rulings sheet was
written — first card to the wrong seat, a seat entitled to a hand dealt out, one
of the first two cards exposed — and it had no way to do it. The only control
that ended a live hand was Next Hand, which says out loud that it takes the money
off the table without paying anybody. That is the right answer for an abandoned
pot and the wrong one for a misdeal, where the blinds and the calls belong to the
people who put them in.
Undo covers the first second of it: "↶ dealing the hand" is one tap. But the
action history lives in memory, so a dealer who is three calls in, or who has
refreshed the page mid-hand, has nothing — and a refresh mid-hand is exactly the
situation this tool has spent rounds making survivable everywhere else.
Misdeal appears in the header while a hand is out and goes away when it isn't:
before the deal there is nothing to take back, and afterwards it would be
offering to unwind a hand that has already been paid. It says what it will do to
the money — "$25 goes back to the players who put it in, cards are cleared, and
the same button deals again" — and adds a note when two or more players have
already acted, because most rooms let a deal stand at that point. It doesn't
refuse: which conditions make a misdeal is the dealer's call and the sheet is
right there.
What comes back is everything, including chips already swept into hand totals on
an earlier street — the first version returned only the current street's and
would have quietly kept a flop's betting. What stays is the button, the blinds,
the straddle and anyone posting in: a misdeal is the same hand dealt again, not
the next one, so the hand number doesn't move and nothing is logged.
13 new tests, with the sweep and the straddle broken on purpose to confirm they
catch it. Measured through the UI: pot $25 with three players in, take it back,
every stack is exactly $200 again, the bank still balances, and dealing again
posts the same blinds on the same players from the same button. 156 audits with
the button live, including a landscape header that stays one row at 46px.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 1 +
.../wwwroot/js/casino/holdem/betting.js | 49 ++++++
.../wwwroot/js/casino/holdem/main.js | 25 +++
.../wwwroot/js/casino/holdem/render.js | 11 ++
tests/holdem/misdeal.test.js | 155 ++++++++++++++++++
5 files changed, 241 insertions(+)
create mode 100644 tests/holdem/misdeal.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 40dbd8c..7a65cbb 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -14,6 +14,7 @@
Quick Split
+ MisdealNext Hand ⟳Resolve PotReset Table
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 07f9b21..94aada2 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -459,6 +459,55 @@ const Betting = {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
+ // A misdeal: the hand never happened, so every chip goes back where it came from.
+ //
+ // The rulings sheet in this tool has told dealers to redeal since the day it was
+ // written — first card to the wrong seat, a seat entitled to a hand dealt out, one
+ // of the first two cards exposed — and the tool had no way to do it. The only
+ // control that ended a live hand was Next Hand, which says out loud that it takes
+ // the money off the table without paying anybody. That is right for an abandoned
+ // pot and wrong for a misdeal, where the blinds and any calls belong to the people
+ // who put them in.
+ //
+ // Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
+ // while the action history is still there. It lives in memory, so a dealer who
+ // refreshes the page mid-hand, or who is three calls in, has nothing.
+ //
+ // What is deliberately kept: the button, the blinds, the straddle and anyone
+ // posting in. A misdeal is the *same* hand dealt again, not the next one.
+ takeHandBack(state) {
+ const returned = [];
+ state.seats.forEach((player, index) => {
+ const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
+ if (back > 0) {
+ player.stack = this.round(this.stackOf(state, player) + back);
+ returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
+ }
+ player.committed = 0;
+ player.amountOverride = null;
+ player.hasActed = false;
+ player.folded = false;
+ player.cards = ["", ""];
+ });
+
+ state.bettingOpen = false;
+ state.street = "preflop";
+ state.currentBet = 0;
+ state.lastRaiseSize = 0;
+ state.actingSeatIndex = null;
+ state.streetAggressorIndex = null;
+ state.handAggressorIndex = null;
+ // Nothing was posted, as far as the next rotation is concerned: the hand this
+ // would have counted as is being dealt again from the same button.
+ state.postedBlinds = null;
+ state.postedStraddles = [];
+
+ return {
+ returned,
+ total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)),
+ };
+ },
+
// Pulls the blinds back off the table and posts them again from wherever the
// button and the blind assignment now say they belong. Refunding first is what
// keeps the chip count straight when the button gets passed round three or four
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 8f48301..4f40c65 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -401,6 +401,31 @@ document.addEventListener("DOMContentLoaded", () => {
Render.renderAll();
});
+ // A misdeal. Everything in the middle goes back to whoever put it there, and the
+ // same button deals the same hand again.
+ document.getElementById("misdealBtn").addEventListener("click", async () => {
+ const inPlay = HoldemState.unresolvedContributions(state);
+ const acted = state.seats.filter(p => p.hasActed).length;
+
+ const ok = await Dialog.confirm({
+ title: "Misdeal — take the hand back?",
+ message: inPlay > Betting.EPSILON_CHIPS
+ ? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` +
+ `button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}`
+ : "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.",
+ confirmLabel: "Take it back",
+ });
+ if (!ok) return;
+
+ // The label is both the toast a dealer reads now and the words on the undo
+ // control afterwards, so it says what happened to the money rather than naming
+ // the ruling: "the misdeal" told a table nothing about where their chips went.
+ const label = inPlay > Betting.EPSILON_CHIPS
+ ? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.`
+ : "Misdeal — nothing was in yet, same button deals again.";
+ runUndoable(label, () => Betting.takeHandBack(state));
+ });
+
document.getElementById("nextHandBtn").addEventListener("click", async () => {
// Moving on wipes every seat's contribution, so an unresolved pot is simply
// destroyed — measured at a four-handed table, pressing this with $8 in the
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index f9c38f5..43a4cba 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -33,6 +33,7 @@ const Render = {
this.renderNotes();
this.updatePotBadge();
this.renderSetupFields();
+ this.renderHeaderActions();
this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
@@ -62,6 +63,16 @@ const Render = {
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
},
+ // Misdeal is only a thing you can call on a hand that is out. Before the deal there
+ // is nothing to take back, and afterwards the header would be offering to unwind a
+ // hand that has already been paid — so the button comes and goes with the hand
+ // rather than sitting there all night waiting to be pressed by accident.
+ renderHeaderActions() {
+ const misdeal = document.getElementById("misdealBtn");
+ if (!misdeal) return;
+ misdeal.hidden = !this.state.bettingOpen;
+ },
+
// Whether this device can actually drag a seat. HTML5 drag events are never
// generated from a touch screen, so on a phone the last clause of the hint below
// described something impossible — on the device the rest of the sentence was
diff --git a/tests/holdem/misdeal.test.js b/tests/holdem/misdeal.test.js
new file mode 100644
index 0000000..2a4b737
--- /dev/null
+++ b/tests/holdem/misdeal.test.js
@@ -0,0 +1,155 @@
+// Taking a hand back.
+//
+// The rulings sheet in this tool has told dealers to redeal since the day it was
+// written — first card to the wrong seat, a seat entitled to a hand dealt out, one of
+// the first two cards exposed — and the tool had no way to do it. The only control that
+// ended a live hand was Next Hand, which says out loud that it takes the money off the
+// table without paying anybody. That is right for an abandoned pot and wrong for a
+// misdeal, where the blinds and the calls belong to the people who put them in.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Money, Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
+
+const dealt = (extra = {}) => {
+ const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10, dealerSeatIndex: 0, ...extra });
+ Betting.beginHand(state);
+ return state;
+};
+
+describe("the money", () => {
+ test("goes back to the people who put it in", () => {
+ const state = dealt();
+ const before = state.seats.map(p => Betting.stackOf(state, p) + Betting.committed(p));
+ Betting.takeHandBack(state);
+ assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
+ });
+
+ test("including calls and raises, not just the blinds", () => {
+ const state = dealt();
+ Betting.raiseTo(state, state.actingSeatIndex, 40);
+ Betting.call(state, state.actingSeatIndex);
+ assert.ok(Betting.potSoFar(state) > 50, "there is real money in the middle");
+
+ const taken = Betting.takeHandBack(state);
+ assert.equal(taken.total, 95, "blinds and both wagers");
+ assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
+ });
+
+ test("and across streets, once chips have been swept into hand totals", () => {
+ const state = dealt();
+ [3, 4, 5, 0, 1, 2].forEach(seat => {
+ if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
+ });
+ Betting.closeStreet(state);
+ assert.ok(state.seats.some(p => (Number(p.amountOverride) || 0) > 0), "the flop's money is banked");
+
+ Betting.takeHandBack(state);
+ assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
+ assert.deepEqual(state.seats.map(p => p.amountOverride), [null, null, null, null, null, null]);
+ });
+
+ test("the table has exactly the chips it started with", () => {
+ const fresh = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
+ const total = chipsOnTable(fresh);
+ const state = dealt();
+ Betting.raiseTo(state, state.actingSeatIndex, 40);
+ Betting.takeHandBack(state);
+ assert.equal(chipsOnTable(state), total);
+ });
+
+ test("nothing is thrown away — the discard pile doesn't move", () => {
+ const state = dealt();
+ state.discarded = 8;
+ Betting.takeHandBack(state);
+ assert.equal(state.discarded, 8, "a misdeal is not an abandoned pot");
+ });
+
+ test("it says what went back and to whom", () => {
+ const state = dealt();
+ const taken = Betting.takeHandBack(state);
+ assert.equal(taken.returned.length, 2, "the two blinds");
+ assert.deepEqual(taken.returned.map(r => r.amount).sort((a, b) => a - b), [5, 10]);
+ assert.ok(taken.returned.every(r => r.name), "named, because the dealer says it out loud");
+ });
+});
+
+describe("the table afterwards", () => {
+ test("is waiting to deal the same hand again", () => {
+ const state = dealt();
+ Betting.takeHandBack(state);
+ assert.equal(state.bettingOpen, false, "the dealer says when");
+ assert.equal(state.street, "preflop");
+ assert.equal(state.actingSeatIndex, null);
+ assert.equal(state.currentBet, 0);
+ });
+
+ test("keeps the button where it was — a misdeal is not the next hand", () => {
+ const state = dealt({ dealerSeatIndex: 3 });
+ const handNumber = state.handNumber;
+ Betting.takeHandBack(state);
+ assert.equal(state.dealerSeatIndex, 3);
+ assert.equal(state.handNumber, handNumber, "no hand has been played");
+ assert.equal((state.handHistory || []).length, 0, "and none is logged");
+ });
+
+ test("keeps the straddle, which was announced and still stands", () => {
+ const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
+ state.straddleCount = 1;
+ Betting.beginHand(state);
+ assert.deepEqual(state.postedStraddles, [3]);
+
+ Betting.takeHandBack(state);
+ assert.equal(state.straddleCount, 1, "still on for the redeal");
+ assert.deepEqual(state.postedStraddles, [], "but nothing is out there yet");
+
+ Betting.beginHand(state);
+ assert.deepEqual(state.postedStraddles, [3], "and it goes out again");
+ });
+
+ test("clears the cards and the folds, because none of it happened", () => {
+ const state = dealt();
+ state.seats[3].cards = ["As", "Kd"];
+ Betting.fold(state, 3);
+ Betting.takeHandBack(state);
+ assert.deepEqual(state.seats[3].cards, ["", ""]);
+ assert.equal(state.seats[3].folded, false);
+ assert.ok(state.seats.every(p => !p.hasActed));
+ });
+
+ test("deals again cleanly, with the same blinds on the same players", () => {
+ const state = dealt();
+ const first = state.seats.map(p => Betting.committed(p));
+ Betting.takeHandBack(state);
+ Betting.beginHand(state);
+ assert.deepEqual(state.seats.map(p => Betting.committed(p)), first);
+ });
+});
+
+describe("the bank still balances", () => {
+ test("through a misdeal and the redeal after it", () => {
+ const state = dealt();
+ state.seats.forEach(p => { p.buyIns = 200; });
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+
+ Betting.raiseTo(state, state.actingSeatIndex, 60);
+ Betting.takeHandBack(state);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+
+ Betting.beginHand(state);
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.balanced, true);
+ assert.equal(bank.cashIn, 1200);
+ });
+});
+
+describe("taking back a hand nobody had money in", () => {
+ test("is harmless: no blinds, nothing moves", () => {
+ const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 0, bigBlind: 0 });
+ Betting.beginHand(state);
+ const taken = Betting.takeHandBack(state);
+ assert.equal(taken.total, 0);
+ assert.deepEqual(taken.returned, []);
+ assert.equal(state.bettingOpen, false);
+ });
+});
From 55eb569d3b366a6015b620044606e4b6e3fe48c5 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 08:37:44 -0600
Subject: [PATCH 50/55] Keep the list of who's next, instead of leaving it on a
napkin
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A full home game always has two or three people standing behind the chairs, and
the list of who's next lives on a napkin or in the dealer's head. That is where
the argument an hour later comes from. The tool had just learned to open a chair
— somebody cashes out and the seat is free — and had nothing to say about who
should sit in it.
The Session panel keeps the list in order of arrival, which is the only ordering
anybody has ever agreed to. Whoever is at the front is offered the first open
chair by number, and everybody else waits their turn: a list that isn't kept in
order is a pile of names, and jumping the queue is the thing a dealer gets
shouted at for. With the table full it says so rather than offering a seat that
doesn't exist. The same offer appears on the empty chair's own menu — "Seat
Priya" — because tapping the chair somebody is about to sit in is what a dealer's
hand does anyway.
Sitting somebody down puts their name on the chair and nothing else. No stack, no
buy-in, not dealt in: they haven't paid yet, and inventing chips for them would
put money on the table nobody has handed over, which is the one thing the bank
check exists to catch. Add chips is the next thing the dealer does, and it is one
tap away in the same menu.
Two people called Mike are two people — the list is keyed by id, not by name.
Names are trimmed, blanks are refused, and a saved list from a table that never
had one comes back empty rather than undefined.
15 new tests, with the occupied-chair guard removed and a stack handed to the new
player on purpose to confirm they fail. Walked through the browser: two names
added to a full table, a player cashed out to open seat 2, the front of the queue
seated there with nothing in front of them, the bank still balanced, and the
second name taken off the list. 156 audits with the list populated, including a
name long enough to test the row.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 8 +
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 80 +++++++++
.../wwwroot/js/casino/holdem/main.js | 39 ++++
.../wwwroot/js/casino/holdem/render.js | 54 ++++++
.../wwwroot/js/casino/holdem/seatMenu.js | 15 ++
.../wwwroot/js/casino/holdem/state.js | 67 +++++++
tests/holdem/waitingList.test.js | 167 ++++++++++++++++++
7 files changed, 430 insertions(+)
create mode 100644 tests/holdem/waitingList.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 7a65cbb..ad5b098 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -146,6 +146,14 @@
Everyone who has bought in shows up here, with what they'd be owed if the night ended now.
+
Waiting list
+
+
+
Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.
+
Cash taken
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index ebabf22..5c28d01 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -2312,6 +2312,86 @@ body.hasActionBar #holdemSidebar {
font-style: italic;
}
+/* The people standing behind the chairs. A short list in a panel that already has a
+ lot in it, so it is built from rows rather than a table: a name, the seat it can go
+ in, and a way off the list. */
+.waitingAdd {
+ display: flex;
+ gap: 0.4rem;
+ margin-bottom: 0.5rem;
+}
+
+.waitingAdd input {
+ flex: 1;
+ min-width: 0;
+ min-height: 44px;
+ background: #141414;
+ border: 1px solid #3a3a3a;
+ border-radius: 8px;
+ color: #eee;
+ font-family: inherit;
+ /* 16px, or iOS zooms the whole page in when it takes focus. */
+ font-size: 16px;
+ padding: 0.35rem 0.6rem;
+}
+
+.waitingAdd .holdemBtn {
+ flex: none;
+ min-height: 44px;
+}
+
+.waitingList {
+ list-style: none;
+ counter-reset: waiting;
+ margin: 0 0 0.5rem;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.waitingRow {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ counter-increment: waiting;
+}
+
+/* The position in the queue, which is the whole point of the list. */
+.waitingRow::before {
+ content: counter(waiting) ".";
+ flex: none;
+ min-width: 1.2rem;
+ color: #9a9a9a;
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.waitingName {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #e8e8e8;
+ font-size: 0.85rem;
+}
+
+.waitingNote {
+ flex: none;
+ color: #9a9a9a;
+ font-size: 0.75rem;
+}
+
+.waitingRow .holdemBtn {
+ flex: none;
+ min-height: 44px;
+}
+
+.waitingDropBtn {
+ min-width: 44px;
+}
+
/* ---------- Side-pot breakdown ---------- */
.potBreakdown {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 4f40c65..07084bf 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -56,6 +56,35 @@ document.addEventListener("DOMContentLoaded", () => {
},
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
+
+ // The waiting list, from the Session panel and from an empty chair's menu.
+ // The state change goes inside runUndoable, not before it: the snapshot is
+ // taken as the first thing that happens, so anything done ahead of it is
+ // already baked into what Undo would restore.
+ seatWaiting: (id, index) => {
+ const entry = (state.waitingList || []).find(row => row.id === id);
+ const seat = state.seats[index];
+ if (!entry || !seat) {
+ Toast.show("They aren't on the list any more.", { duration: 3000 });
+ hooks.rerender();
+ return;
+ }
+ const occupied = (seat.name || "").trim() || Number(seat.buyIns) > 0 || Number(seat.stack) > 0;
+ if (occupied) {
+ Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
+ hooks.rerender();
+ return;
+ }
+ runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
+ () => HoldemState.seatFromWaitingList(state, id, index));
+ },
+
+ dropWaiting: id => {
+ const entry = (state.waitingList || []).find(row => row.id === id);
+ if (!entry) return;
+ runUndoable(`${entry.name} taken off the waiting list.`,
+ () => HoldemState.removeFromWaitingList(state, id));
+ },
onGiveButton: index => seatMenuHooks.makeDealer(index),
};
@@ -403,6 +432,16 @@ document.addEventListener("DOMContentLoaded", () => {
// A misdeal. Everything in the middle goes back to whoever put it there, and the
// same button deals the same hand again.
+ document.getElementById("waitingListForm").addEventListener("submit", e => {
+ e.preventDefault();
+ const field = document.getElementById("waitingNameInput");
+ const name = field.value.trim();
+ if (!name) return;
+ field.value = "";
+ runUndoable(`${name} added to the waiting list.`, () => HoldemState.addToWaitingList(state, name));
+ field.focus();
+ });
+
document.getElementById("misdealBtn").addEventListener("click", async () => {
const inPlay = HoldemState.unresolvedContributions(state);
const acted = state.seats.filter(p => p.hasActed).length;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 43a4cba..b245a25 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -1058,9 +1058,63 @@ const Render = {
this.renderBankReconciliation();
this.renderSettleUp(standings);
+ this.renderWaitingList();
this.renderStorageWarning();
},
+ // Who is standing behind the chairs, in the order they turned up. Each one carries
+ // the two things a dealer does with them: sit them down, or take them off the list
+ // because they've gone to the bar.
+ renderWaitingList() {
+ const list = document.getElementById("waitingList");
+ const hint = document.getElementById("waitingEmptyHint");
+ if (!list) return;
+
+ const waiting = this.state.waitingList || [];
+ const open = HoldemState.openSeats(this.state);
+ list.innerHTML = "";
+ hint.hidden = waiting.length > 0;
+
+ waiting.forEach((entry, position) => {
+ const row = document.createElement("li");
+ row.className = "waitingRow";
+
+ const name = document.createElement("span");
+ name.className = "waitingName";
+ name.textContent = entry.name;
+ row.appendChild(name);
+
+ // Only the person at the front is offered a seat, because a list nobody
+ // keeps in order is just a pile of names — and jumping the queue is the
+ // thing the dealer gets shouted at for.
+ if (position === 0 && open.length > 0) {
+ const seat = document.createElement("button");
+ seat.type = "button";
+ seat.className = "holdemBtn holdemBtnSmall waitingSeatBtn";
+ seat.textContent = `Seat ${open[0] + 1}`;
+ seat.title = `Sit ${entry.name} in seat ${open[0] + 1}`;
+ seat.addEventListener("click", () => this.hooks.seatWaiting(entry.id, open[0]));
+ row.appendChild(seat);
+ } else if (position === 0) {
+ const full = document.createElement("span");
+ full.className = "waitingNote";
+ full.textContent = "table full";
+ row.appendChild(full);
+ }
+
+ const drop = document.createElement("button");
+ drop.type = "button";
+ drop.className = "holdemBtn holdemBtnSmall holdemBtnGhost waitingDropBtn";
+ drop.textContent = "✕";
+ drop.setAttribute("aria-label", `Take ${entry.name} off the waiting list`);
+ drop.title = `Take ${entry.name} off the list`;
+ drop.addEventListener("click", () => this.hooks.dropWaiting(entry.id));
+ row.appendChild(drop);
+
+ list.appendChild(row);
+ });
+ },
+
// A toast is a moment; this table not being saved is a fact about the rest of the
// night. It sits at the top of the panel a dealer opens to check the money, and it
// stays there, because the raise pad dismisses toasts and the one time this warning
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
index a90c5b2..8655f7a 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
@@ -47,6 +47,21 @@ const SeatMenu = {
run: () => this.hooks.toggleDealtIn(index),
});
+ // An empty chair with people waiting: the dealer taps the chair somebody is
+ // about to sit in, which is what their hand is doing anyway. Only the front of
+ // the queue is offered, for the same reason the panel only offers them — a list
+ // that isn't kept in order is just a pile of names.
+ const waiting = (this.state.waitingList || [])[0];
+ const emptyChair = !(player.name || "").trim() &&
+ !(Number(player.buyIns) > 0) && !(Number(player.stack) > 0);
+ if (waiting && emptyChair) {
+ actions.push({
+ label: `Seat ${waiting.name}`,
+ className: "menuAccent",
+ run: () => this.hooks.seatWaiting(waiting.id, index),
+ });
+ }
+
// Leaving for the night, which is a different thing from sitting out a hand:
// the chips go back over the rail and the chair opens. Offered only for a seat
// that has somebody in it, since an empty chair has nobody to pay.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index c8ae787..717d8c5 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -115,6 +115,10 @@ const HoldemState = {
// out, and both halves have to stay on the record or the bank check reads
// as a shortfall for the rest of the evening.
cashedOut: [],
+ // People standing behind the chairs. A dealer at a full table keeps this
+ // list on a napkin; keeping it here means the tool can offer the next seat
+ // that opens to whoever has been waiting longest.
+ waitingList: [],
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
@@ -233,6 +237,16 @@ const HoldemState = {
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
+ parsed.waitingList = Array.isArray(parsed.waitingList)
+ ? parsed.waitingList
+ .map(row => (typeof row === "string" ? { id: null, name: row } : row))
+ .filter(row => row && String(row.name || "").trim())
+ .map(row => ({
+ id: row.id || this.makeId("waiting"),
+ name: String(row.name).trim().slice(0, 24),
+ addedAt: Number(row.addedAt) || 0,
+ }))
+ : [];
parsed.cashedOut = Array.isArray(parsed.cashedOut)
? parsed.cashedOut
.filter(row => row && row.id)
@@ -875,6 +889,59 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
+ // ---- The people standing behind the chairs ----
+ //
+ // A full home game always has a couple of them, and the list lives on a napkin or
+ // in the dealer's head — which is where the argument comes from an hour later about
+ // who said they were next. Kept in order of arrival, because that is the only
+ // ordering anybody has ever agreed to.
+
+ addToWaitingList(state, name) {
+ const trimmed = String(name || "").trim().slice(0, 24);
+ if (!trimmed) return null;
+ if (!Array.isArray(state.waitingList)) state.waitingList = [];
+ const entry = { id: this.makeId("waiting"), name: trimmed, addedAt: Date.now() };
+ state.waitingList.push(entry);
+ return entry;
+ },
+
+ removeFromWaitingList(state, id) {
+ if (!Array.isArray(state.waitingList)) return null;
+ const index = state.waitingList.findIndex(row => row.id === id);
+ if (index < 0) return null;
+ return state.waitingList.splice(index, 1)[0];
+ },
+
+ // Chairs with nobody in them, in seat order. What "a seat has opened" means, and
+ // the same question the deal prompt answers when it counts the players.
+ openSeats(state) {
+ return state.seats
+ .map((player, index) => ({ player, index }))
+ .filter(({ player }) => !(player.name || "").trim() &&
+ !(Number(player.buyIns) > 0) && !(Number(player.stack) > 0))
+ .map(({ index }) => index);
+ },
+
+ // Sit the next person down. Their name goes on the chair and nothing else does:
+ // they haven't bought in yet, and guessing a stack for them would put chips on the
+ // table that nobody has paid for — which is the one thing the bank check exists to
+ // catch.
+ seatFromWaitingList(state, id, index) {
+ const seat = state.seats[index];
+ if (!seat) return { ok: false, reason: "There's no such seat." };
+ if ((seat.name || "").trim() || Number(seat.buyIns) > 0 || Number(seat.stack) > 0) {
+ return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
+ }
+ const entry = this.removeFromWaitingList(state, id);
+ if (!entry) return { ok: false, reason: "They aren't on the list any more." };
+
+ seat.name = entry.name;
+ // Not dealt in and holding nothing: the next thing that happens is the dealer
+ // taking their money, and Add chips is what says so.
+ seat.inHand = false;
+ return { ok: true, name: entry.name, index };
+ },
+
// ---- Somebody leaving with their chips ----
//
// The most ordinary thing that happens at a home game after midnight, and the tool
diff --git a/tests/holdem/waitingList.test.js b/tests/holdem/waitingList.test.js
new file mode 100644
index 0000000..ec791bd
--- /dev/null
+++ b/tests/holdem/waitingList.test.js
@@ -0,0 +1,167 @@
+// The people standing behind the chairs.
+//
+// A full home game always has a couple of them, and the list lives on a napkin or in
+// the dealer's head — which is where the argument comes from an hour later about who
+// said they were next. Order of arrival, because it is the only ordering anybody has
+// ever agreed to.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { HoldemState } = require("./load");
+
+const table = (seated = 2, seats = 4) => {
+ const state = HoldemState.createDefault();
+ HoldemState.resizeSeats(state, seats);
+ ["Ann", "Ben", "Cal", "Dee"].slice(0, seated).forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].stack = 200;
+ state.seats[i].buyIns = 200;
+ });
+ return state;
+};
+
+describe("keeping the list", () => {
+ test("names go on in the order they arrive", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ HoldemState.addToWaitingList(state, "Sam");
+ assert.deepEqual(state.waitingList.map(row => row.name), ["Priya", "Sam"]);
+ });
+
+ test("whitespace is trimmed and nothing empty gets on", () => {
+ const state = table();
+ assert.ok(HoldemState.addToWaitingList(state, " Priya "));
+ assert.equal(HoldemState.addToWaitingList(state, " "), null);
+ assert.equal(HoldemState.addToWaitingList(state, ""), null);
+ assert.deepEqual(state.waitingList.map(row => row.name), ["Priya"]);
+ });
+
+ test("two people with the same name are two people", () => {
+ // Home games have two Mikes. The list is keyed by id, not by name.
+ const state = table();
+ HoldemState.addToWaitingList(state, "Mike");
+ HoldemState.addToWaitingList(state, "Mike");
+ assert.equal(state.waitingList.length, 2);
+ assert.notEqual(state.waitingList[0].id, state.waitingList[1].id);
+
+ HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
+ assert.equal(state.waitingList.length, 1, "only one of them leaves");
+ });
+
+ test("taking somebody off gives them back, so the toast can name them", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ const gone = HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
+ assert.equal(gone.name, "Priya");
+ assert.deepEqual(state.waitingList, []);
+ assert.equal(HoldemState.removeFromWaitingList(state, "not-a-real-id"), null);
+ });
+});
+
+describe("which chairs are open", () => {
+ test("the ones with nobody in them, in seat order", () => {
+ assert.deepEqual(HoldemState.openSeats(table(2, 4)), [2, 3]);
+ assert.deepEqual(HoldemState.openSeats(table(4, 4)), []);
+ });
+
+ test("a chair with chips or a buy-in on it is not open", () => {
+ // Somebody who has stepped away from the table still owns their seat.
+ const state = table(2, 4);
+ state.seats[2].stack = 120;
+ state.seats[3].buyIns = 100;
+ assert.deepEqual(HoldemState.openSeats(state), []);
+ });
+});
+
+describe("sitting somebody down", () => {
+ test("their name goes on the chair and they come off the list", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ HoldemState.addToWaitingList(state, "Sam");
+ const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
+
+ assert.equal(result.ok, true);
+ assert.equal(state.seats[2].name, "Priya");
+ assert.deepEqual(state.waitingList.map(row => row.name), ["Sam"]);
+ });
+
+ test("with nothing in front of them, because they haven't bought in yet", () => {
+ // Guessing a stack would put chips on the table nobody has paid for, which is
+ // the one thing the bank check exists to catch.
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
+
+ assert.equal(state.seats[2].stack, null);
+ assert.equal(state.seats[2].buyIns, 0);
+ assert.equal(state.seats[2].inHand, false, "they're in the next hand, not this one");
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+
+ test("never into a chair somebody is already in", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 0);
+ assert.equal(result.ok, false);
+ assert.match(result.reason, /Ann is already/);
+ assert.equal(state.waitingList.length, 1, "and they keep their place in the queue");
+ });
+
+ test("nor from a name that has already been seated", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ const id = state.waitingList[0].id;
+ HoldemState.seatFromWaitingList(state, id, 2);
+ const again = HoldemState.seatFromWaitingList(state, id, 3);
+ assert.equal(again.ok, false);
+ assert.equal(state.seats[3].name, "", "the second chair is untouched");
+ });
+
+ test("a seat that doesn't exist is refused rather than invented", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ assert.equal(HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 99).ok, false);
+ assert.equal(state.waitingList.length, 1);
+ });
+});
+
+describe("a seat opening up", () => {
+ test("cashing somebody out is what makes room for the next one", () => {
+ const state = table(4, 4);
+ HoldemState.addToWaitingList(state, "Priya");
+ assert.deepEqual(HoldemState.openSeats(state), [], "the table is full");
+
+ HoldemState.cashOut(state, 1);
+ assert.deepEqual(HoldemState.openSeats(state), [1]);
+
+ const seated = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 1);
+ assert.equal(seated.ok, true);
+ assert.equal(state.seats[1].name, "Priya");
+ // Ben's night is still on the record, and the money still adds up.
+ assert.equal(state.cashedOut.length, 1);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+});
+
+describe("what a saved table remembers", () => {
+ test("the list survives a reload", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ const migrated = HoldemState.migrate(JSON.parse(JSON.stringify(state)));
+ assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya"]);
+ });
+
+ test("a table saved before the list existed has nobody waiting", () => {
+ const parsed = JSON.parse(JSON.stringify(table()));
+ delete parsed.waitingList;
+ assert.deepEqual(HoldemState.migrate(parsed).waitingList, []);
+ });
+
+ test("junk in a saved list is cleaned up rather than trusted", () => {
+ const parsed = JSON.parse(JSON.stringify(table()));
+ parsed.waitingList = [{ name: " Priya " }, { name: "" }, null, "Sam"];
+ const migrated = HoldemState.migrate(parsed);
+ assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya", "Sam"]);
+ assert.ok(migrated.waitingList.every(row => row.id), "and everybody gets an id");
+ });
+});
From 1689ecb4f06178d4a6f2c09d205d1d0e44be4277 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 08:52:56 -0600
Subject: [PATCH 51/55] Count the chips the table is actually playing with
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"Starting stack" is how a night opens. The setup panel offers it and says what it
means — "seats without their own stack start here" — and everything a dealer
looks at reads it: the puck, the action bar, what a player is allowed to bet.
The money side didn't. The bank check and the standings read `seat.stack`
directly, which is null for exactly those seats. Measured on a three-handed table
with a $200 starting stack and nobody's own stack typed in: the felt played for
$600, the bank check reported no chips in play and pronounced itself balanced,
and the standings — the list a dealer settles the night from — listed nobody at
all. Three players, six hundred dollars, and the tool's own accounting said the
table was empty.
Both now ask the same question the felt asks. The check reports $600 in play
against nothing taken, which is the truth: those chips have not been paid for.
Empty chairs are excluded, or the starting stack would make every one of them
look like somebody holding two hundred dollars — a ten-seat table would have
opened with $2,000 of imaginary chips.
Being told is only half of it. The verdict names the seats rather than saying
"somebody's rebuy probably wasn't recorded", because the dealer's next move is to
go to a particular chair and the tool knows which. And the seats holding the
table's starting stack — the dealer's own instruction, said once in the setup
panel — can be written down in one tap instead of six trips through Add chips.
Only those: what somebody's forgotten rebuy was is a question only the dealer can
answer, and guessing it would be the tool inventing money, which is the fault
this round is about.
14 new tests, with the stack source and the empty-chair test broken on purpose to
confirm they fail. Walked through in the browser from a cleared table: set the
starting stack, name three seats, and the panel goes from "balanced, no chips" to
"$600 more in chips than cash taken. Ann, Ben and Cal are holding chips with no
buy-in written down" — then to balanced, with the buy-ins recorded and undoable.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 1 +
.../wwwroot/js/casino/holdem/main.js | 18 ++
.../wwwroot/js/casino/holdem/render.js | 33 +++-
.../wwwroot/js/casino/holdem/state.js | 71 +++++++-
tests/holdem/startingStackMoney.test.js | 166 ++++++++++++++++++
5 files changed, 284 insertions(+), 5 deletions(-)
create mode 100644 tests/holdem/startingStackMoney.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index ad5b098..4d47fce 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -176,6 +176,7 @@
+
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 07084bf..59881ed 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -432,6 +432,24 @@ document.addEventListener("DOMContentLoaded", () => {
// A misdeal. Everything in the middle goes back to whoever put it there, and the
// same button deals the same hand again.
+ // The seats holding the table's starting stack, written down as cash taken.
+ document.getElementById("bankRecordBuyInsBtn").addEventListener("click", async () => {
+ const starting = HoldemState.seatsOwingCash(state).filter(row => row.fromStartingStack);
+ if (starting.length === 0) return;
+ const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
+
+ const ok = await Dialog.confirm({
+ title: `Write down ${Money.format(total)} taken?`,
+ message: `${starting.map(row => `${row.name} ${Money.format(row.chips)}`).join(", ")} — the starting stack ` +
+ `each of them is playing with. Only do this if the cash is actually in the box.`,
+ confirmLabel: "Write it down",
+ });
+ if (!ok) return;
+
+ runUndoable(`${Money.format(total)} recorded from ${starting.length} ${starting.length === 1 ? "player" : "players"}.`,
+ () => HoldemState.recordStartingBuyIns(state));
+ });
+
document.getElementById("waitingListForm").addEventListener("submit", e => {
e.preventDefault();
const field = document.getElementById("waitingNameInput");
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index b245a25..12eb7fb 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -1264,14 +1264,43 @@ const Render = {
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
- verdict.textContent =
- `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
+ // Name them. "Somebody's rebuy wasn't recorded" is true and useless; the
+ // dealer's next move is to go to a particular seat, and the tool knows
+ // which. The commonest cause by far is the table's own starting stack:
+ // seats holding chips that were never bought, because "everyone starts
+ // with 200" is how a night opens.
+ const owing = HoldemState.seatsOwingCash(this.state);
+ const names = owing.map(row => row.name);
+ const who = names.length === 1
+ ? names[0]
+ : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
+ verdict.textContent = owing.length > 0
+ ? `${Money.format(bank.difference)} more in chips than cash taken. ${who} ` +
+ `${owing.length === 1 ? "is" : "are"} holding chips with no buy-in written down.`
+ : `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
} else {
verdict.textContent =
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
}
verdict.classList.toggle("bankBalanced", bank.balanced);
verdict.classList.toggle("bankOff", !bank.balanced);
+
+ // One tap for the seats that are only holding the table's starting stack —
+ // which is the dealer's own instruction, said once in the setup panel, rather
+ // than the tool inventing cash. Anybody else's unrecorded rebuy is named in the
+ // sentence above and left to them, because only they know what it was.
+ const record = document.getElementById("bankRecordBuyInsBtn");
+ if (record) {
+ const starting = HoldemState.seatsOwingCash(this.state).filter(row => row.fromStartingStack);
+ const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
+ record.hidden = starting.length === 0;
+ if (starting.length > 0) {
+ record.textContent = `Take ${Money.format(total)} from ${starting.length} ${starting.length === 1 ? "player" : "players"}`;
+ record.title = starting.length === 1
+ ? `Write down ${Money.format(total)} taken from ${starting[0].name}`
+ : `Write down the starting stack as cash taken from ${starting.map(row => row.name).join(", ")}`;
+ }
+ }
},
// ---- Hand counter + history ----
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 717d8c5..420ab27 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -803,7 +803,7 @@ const HoldemState = {
if (totals.has(player.id)) return;
const hasMoneyInPlay =
(Number(player.buyIns) || 0) > 0 ||
- (Number(player.stack) || 0) > 0 ||
+ this.chipsHeldBy(state, player) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
(Number(player.committed) || 0) > 0;
if (!hasMoneyInPlay) return;
@@ -821,7 +821,7 @@ const HoldemState = {
// the way out. It is no longer on the table, so it is marked as paid
// rather than counted again at the end of the night.
const chipsNow = seat
- ? Money.round(Number(seat.stack) || 0)
+ ? this.chipsHeldBy(state, seat)
: (gone ? gone.chips : 0);
// Chips already pushed into the hand in progress are still this
// player's money until it's won. Leaving them out makes the cash
@@ -1081,6 +1081,71 @@ const HoldemState = {
};
},
+ // Is anybody actually in this chair? A seat with a name on it, chips in front of
+ // it, cash against it or a hand dealt to it. Empty chairs are not players, which
+ // matters the moment the table's starting stack starts standing in for a seat that
+ // has never been given one — every empty chair would otherwise look like somebody
+ // holding two hundred dollars.
+ seatInPlay(player) {
+ if (!player) return false;
+ return !!(player.name || "").trim() ||
+ (Number(player.buyIns) || 0) > 0 ||
+ (Number(player.stack) || 0) > 0 ||
+ (Number(player.committed) || 0) > 0 ||
+ (Number(player.amountOverride) || 0) > 0 ||
+ player.inHand === true;
+ },
+
+ // What a seat is holding, the way the felt and the betting see it — which includes
+ // the table's starting stack standing in for a seat that has never been given one.
+ //
+ // This is the number the money side of the tool was missing. "Starting stack" is
+ // offered in the setup panel as the ordinary way to open a table, and everything a
+ // dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
+ // check and the standings read `seat.stack` directly instead, which is null for
+ // exactly those seats: measured on a three-handed table with a $200 starting stack
+ // and nobody's own stack set, the felt played for $600 while the bank check said
+ // "no chips in play, balanced" and the standings listed nobody at all.
+ chipsHeldBy(state, player) {
+ if (!this.seatInPlay(player)) return 0;
+ return Math.max(0, Money.round(Betting.stackOf(state, player)));
+ },
+
+ // Seats playing with chips the box has no cash against.
+ //
+ // Two kinds, and they are not the same problem. A seat holding the table's starting
+ // stack has never been given chips of its own — the dealer set "everyone starts
+ // with 200" and sat people down, which is the ordinary way to open a table and the
+ // reason this whole gap existed. A seat with its own stack and no buy-in is a
+ // rebuy somebody forgot to write down, and only the dealer knows what it was.
+ seatsOwingCash(state) {
+ return state.seats
+ .map((player, index) => ({ player, index }))
+ .filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
+ !((Number(player.buyIns) || 0) > 0))
+ .map(({ player, index }) => ({
+ index,
+ name: this.displayName(player, index),
+ chips: this.chipsHeldBy(state, player),
+ // True when they are holding the table's starting stack rather than one
+ // the dealer typed for them.
+ fromStartingStack: player.stack === null || player.stack === undefined ||
+ String(player.stack).trim() === "",
+ }));
+ },
+
+ // Write down the cash for the seats that are holding the table's starting stack.
+ // Only those: what somebody else's unrecorded rebuy was is a question only the
+ // dealer can answer, and guessing it would be the tool inventing money.
+ recordStartingBuyIns(state) {
+ const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
+ owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
+ return {
+ seats: owed.length,
+ total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
+ };
+ },
+
bankReconciliation(state) {
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
// Cash the box took from people who have since left counts as taken: it is in
@@ -1091,7 +1156,7 @@ const HoldemState = {
const chipsInPlay = state.seats.reduce(
(sum, p) =>
sum +
- Math.max(0, Number(p.stack) || 0) +
+ this.chipsHeldBy(state, p) +
Math.max(0, Number(p.committed) || 0) +
Math.max(0, Number(p.amountOverride) || 0),
0
diff --git a/tests/holdem/startingStackMoney.test.js b/tests/holdem/startingStackMoney.test.js
new file mode 100644
index 0000000..8c9bc59
--- /dev/null
+++ b/tests/holdem/startingStackMoney.test.js
@@ -0,0 +1,166 @@
+// The chips the felt plays with are the chips the box is checked against.
+//
+// "Starting stack" is offered in the setup panel as the ordinary way to open a table —
+// "seats without their own stack start here" — and everything a dealer looks at reads
+// it: the puck, the action bar, what a player is allowed to bet. The money side did
+// not. It read `seat.stack` directly, which is null for exactly those seats, so a
+// three-handed table playing for $600 reported no chips in play, a balanced bank and a
+// standings list with nobody on it.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Money, HoldemState, Betting } = require("./load");
+
+const openedWithStartingStack = (stack = 200, names = ["Ann", "Ben", "Cal"]) => {
+ const state = HoldemState.createDefault();
+ HoldemState.resizeSeats(state, 6);
+ state.defaultStack = stack;
+ names.forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].inHand = true;
+ });
+ return state;
+};
+
+describe("what the table is playing for", () => {
+ test("is what the bank counts", () => {
+ const state = openedWithStartingStack();
+ // What the felt says each of them has.
+ assert.deepEqual(state.seats.slice(0, 3).map(p => Betting.stackOf(state, p)), [200, 200, 200]);
+ assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
+ });
+
+ test("and the box being empty is a problem it now reports", () => {
+ const bank = HoldemState.bankReconciliation(openedWithStartingStack());
+ assert.equal(bank.cashIn, 0);
+ assert.equal(bank.difference, 600);
+ assert.equal(bank.balanced, false, "six hundred dollars of chips nobody paid for");
+ });
+
+ test("empty chairs are not people holding two hundred dollars", () => {
+ // The starting stack stands in for a seat that hasn't been given one, and every
+ // empty chair is such a seat. Counting them would invent $600 of chips at an
+ // empty table.
+ const state = openedWithStartingStack();
+ assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600, "three players, not six chairs");
+
+ const empty = HoldemState.createDefault();
+ empty.defaultStack = 200;
+ assert.equal(HoldemState.bankReconciliation(empty).chipsInPlay, 0);
+ assert.equal(HoldemState.bankReconciliation(empty).balanced, true);
+ });
+
+ test("once the cash is written down it balances", () => {
+ const state = openedWithStartingStack();
+ state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+
+ test("a table where everyone has their own stack is unchanged", () => {
+ const state = HoldemState.createDefault();
+ HoldemState.resizeSeats(state, 4);
+ ["Ann", "Ben"].forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].stack = 150;
+ state.seats[i].buyIns = 150;
+ });
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.chipsInPlay, 300);
+ assert.equal(bank.balanced, true);
+ });
+});
+
+describe("who is on the settle-up list", () => {
+ test("everyone holding chips, including the ones holding the table's own stack", () => {
+ const rows = HoldemState.sessionStandings(openedWithStartingStack());
+ assert.deepEqual(rows.map(r => r.name), ["Ann", "Ben", "Cal"]);
+ assert.deepEqual(rows.map(r => r.chipsInFront), [200, 200, 200]);
+ });
+
+ test("with nothing bought in yet, which is the thing to notice", () => {
+ const rows = HoldemState.sessionStandings(openedWithStartingStack());
+ assert.deepEqual(rows.map(r => r.boughtIn), [0, 0, 0]);
+ assert.deepEqual(rows.map(r => r.cashPosition), [200, 200, 200]);
+ });
+});
+
+describe("naming the seats the cash is missing for", () => {
+ test("says who, and which kind of missing it is", () => {
+ const state = openedWithStartingStack();
+ state.seats[2].stack = 320; // Cal typed his own: an unrecorded rebuy
+
+ const owing = HoldemState.seatsOwingCash(state);
+ assert.deepEqual(owing.map(row => row.name), ["Ann", "Ben", "Cal"]);
+ assert.deepEqual(owing.map(row => row.fromStartingStack), [true, true, false]);
+ assert.deepEqual(owing.map(row => row.chips), [200, 200, 320]);
+ });
+
+ test("a seat whose buy-in is written down isn't on the list", () => {
+ const state = openedWithStartingStack();
+ state.seats[0].buyIns = 200;
+ assert.deepEqual(HoldemState.seatsOwingCash(state).map(row => row.name), ["Ben", "Cal"]);
+ });
+
+ test("and a table where nothing is owed has nobody on it", () => {
+ const state = openedWithStartingStack();
+ state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
+ assert.deepEqual(HoldemState.seatsOwingCash(state), []);
+ });
+});
+
+describe("writing the starting stacks down", () => {
+ test("records exactly what those seats are holding", () => {
+ const state = openedWithStartingStack();
+ const done = HoldemState.recordStartingBuyIns(state);
+ assert.equal(done.seats, 3);
+ assert.equal(done.total, 600);
+ assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+
+ test("and leaves somebody else's forgotten rebuy alone", () => {
+ // Only the dealer knows what that was. Guessing would be the tool inventing
+ // money, which is the fault this whole round is about.
+ const state = openedWithStartingStack();
+ state.seats[2].stack = 320;
+ HoldemState.recordStartingBuyIns(state);
+
+ assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 0]);
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.balanced, false);
+ assert.equal(bank.difference, 320, "still exactly Cal's chips, and still reported");
+ });
+
+ test("does nothing on a table that doesn't need it", () => {
+ const state = openedWithStartingStack();
+ state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
+ const done = HoldemState.recordStartingBuyIns(state);
+ assert.equal(done.seats, 0);
+ assert.equal(done.total, 0);
+ assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
+ });
+});
+
+describe("through a hand", () => {
+ test("the money adds up from the deal to the payout", () => {
+ const state = openedWithStartingStack(200, ["Ann", "Ben", "Cal", "Dee"]);
+ HoldemState.recordStartingBuyIns(state);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+
+ Betting.beginHand(state);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true, "blinds are still on the table");
+
+ [3, 0, 1].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+
+ const pot = Betting.potSoFar(state);
+ Betting.sweepToHandTotals(state);
+ Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
+ HoldemState.startNextHand(state);
+
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.balanced, true);
+ assert.equal(bank.chipsInPlay, 800, "four buy-ins of two hundred, wherever they have moved to");
+ assert.ok(state.seats[0].stack > 200, "and the winner is holding more than they sat down with");
+ });
+});
From 1c2e71650836eddf5fffcc8cb90d338171edb067 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 09:05:32 -0600
Subject: [PATCH 52/55] Ask the same two questions the same way everywhere
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The round before this found the bank check and the standings reading a seat's own
stack where the felt reads the table's starting stack — two parts of the tool
disagreeing about how much money was on the table. That was worth a sweep, and
the sweep found more.
The colour-up panel had the same fault. It is the panel that says "count out
2×$100 for this player", and on a table opened the ordinary way — a starting
stack and some names — it showed nothing at all. The one table where every stack
is a round number made of the largest chips there are was the table it had
nothing to say about.
The other question, "is anybody in this chair", existed in four spellings: in the
seat count, in the seat menu twice, in the waiting list, and twice more in the
state module. Three of them looked at name, chips and cash; one also looked at a
hand being dealt. A chair that counts as occupied for one and empty for another
is how somebody's chips get given away — measured, a seat marked in hand with no
name and no stack yet was on offer to the next person in the waiting queue, on
top of whoever was already playing it.
Both questions now have one implementation and one name:
"What is this seat holding?" HoldemState.chipsHeldBy
"Is anybody in this chair?" HoldemState.seatInPlay
10 new tests, including one that pins the bank check, the standings and the
settle-up plan to the same total, and one that pins the seat count and the open
seats to the same set of empty chairs. The occupancy test was cut back to its old
three-part form on purpose to confirm the waiting-list case fails.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/main.js | 13 +-
.../wwwroot/js/casino/holdem/render.js | 16 ++-
.../wwwroot/js/casino/holdem/seatMenu.js | 5 +-
.../wwwroot/js/casino/holdem/state.js | 5 +-
tests/holdem/oneStackRule.test.js | 133 ++++++++++++++++++
5 files changed, 154 insertions(+), 18 deletions(-)
create mode 100644 tests/holdem/oneStackRule.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 59881ed..667a39a 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -69,7 +69,7 @@ document.addEventListener("DOMContentLoaded", () => {
hooks.rerender();
return;
}
- const occupied = (seat.name || "").trim() || Number(seat.buyIns) > 0 || Number(seat.stack) > 0;
+ const occupied = HoldemState.seatInPlay(seat);
if (occupied) {
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
hooks.rerender();
@@ -366,12 +366,11 @@ document.addEventListener("DOMContentLoaded", () => {
// in progress. Shrinking the table throws these away permanently, and it also
// orphans them in the session standings, where they keep their winnings but lose
// the stack and buy-in the settle-up is worked out from.
- const seatHasSomeoneIn = player =>
- !!(player.name && player.name.trim()) ||
- player.inHand ||
- (Number(player.stack) || 0) > 0 ||
- (Number(player.buyIns) || 0) > 0 ||
- (Number(player.amountOverride) || 0) > 0;
+ // One question, asked in one place. This predicate existed here, in the seat menu,
+ // in the action bar and twice in the state module, in four slightly different
+ // spellings — and a chair that counts as occupied for the waiting list but empty
+ // for the seat count is how somebody's chips get thrown away.
+ const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
document.getElementById("seatCountInput").addEventListener("change", async e => {
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 12eb7fb..25607fd 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -936,14 +936,20 @@ const Render = {
const list = document.getElementById("colourUpList");
if (!host || !list) return;
+ // What each player is holding, asked the same way the felt asks it — which
+ // includes the table's starting stack standing in for a seat that has never
+ // been given one. Reading `player.stack` here meant that a table opened the
+ // ordinary way, with "everyone starts with 200", showed an empty colour-up
+ // panel: nobody to count out, at the table where every single stack is a
+ // round number made of the largest chips there are.
const legend = this.state.chipLegend || [];
const rows = this.state.seats
- .map((player, index) => ({ player, index }))
- .filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0)
- .map(({ player, index }) => ({
+ .map((player, index) => ({ player, index, held: HoldemState.chipsHeldBy(this.state, player) }))
+ .filter(({ held }) => held > 0)
+ .map(({ player, index, held }) => ({
name: HoldemState.displayName(player, index),
- stack: Betting.round(Number(player.stack) || 0),
- chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend),
+ stack: held,
+ chips: Money.formatChipBreakdown(held, legend),
}))
.filter(row => row.chips);
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
index 8655f7a..c86b436 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
@@ -52,8 +52,7 @@ const SeatMenu = {
// the queue is offered, for the same reason the panel only offers them — a list
// that isn't kept in order is just a pile of names.
const waiting = (this.state.waitingList || [])[0];
- const emptyChair = !(player.name || "").trim() &&
- !(Number(player.buyIns) > 0) && !(Number(player.stack) > 0);
+ const emptyChair = !HoldemState.seatInPlay(player);
if (waiting && emptyChair) {
actions.push({
label: `Seat ${waiting.name}`,
@@ -65,7 +64,7 @@ const SeatMenu = {
// Leaving for the night, which is a different thing from sitting out a hand:
// the chips go back over the rail and the chair opens. Offered only for a seat
// that has somebody in it, since an empty chair has nobody to pay.
- if ((player.name || "").trim() || Number(player.buyIns) > 0 || Number(player.stack) > 0) {
+ if (HoldemState.seatInPlay(player)) {
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 420ab27..7f1d75c 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -917,8 +917,7 @@ const HoldemState = {
openSeats(state) {
return state.seats
.map((player, index) => ({ player, index }))
- .filter(({ player }) => !(player.name || "").trim() &&
- !(Number(player.buyIns) > 0) && !(Number(player.stack) > 0))
+ .filter(({ player }) => !this.seatInPlay(player))
.map(({ index }) => index);
},
@@ -929,7 +928,7 @@ const HoldemState = {
seatFromWaitingList(state, id, index) {
const seat = state.seats[index];
if (!seat) return { ok: false, reason: "There's no such seat." };
- if ((seat.name || "").trim() || Number(seat.buyIns) > 0 || Number(seat.stack) > 0) {
+ if (this.seatInPlay(seat)) {
return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
}
const entry = this.removeFromWaitingList(state, id);
diff --git a/tests/holdem/oneStackRule.test.js b/tests/holdem/oneStackRule.test.js
new file mode 100644
index 0000000..1184831
--- /dev/null
+++ b/tests/holdem/oneStackRule.test.js
@@ -0,0 +1,133 @@
+// One question, one answer.
+//
+// The round before this found the bank check and the standings reading `seat.stack`
+// where the felt reads the table's starting stack — two parts of the tool disagreeing
+// about how much money is on the table. This is the sweep that followed: everywhere
+// else the same two questions get asked, asked the same way.
+//
+// "What is this seat holding?" -> HoldemState.chipsHeldBy
+// "Is anybody in this chair?" -> HoldemState.seatInPlay
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { HoldemState, Betting, SettleUp } = require("./load");
+
+const openedOnTheDefault = (stack = 200) => {
+ const state = HoldemState.createDefault();
+ HoldemState.resizeSeats(state, 6);
+ state.defaultStack = stack;
+ ["Ann", "Ben", "Cal"].forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].inHand = true;
+ state.seats[i].buyIns = stack;
+ });
+ return state;
+};
+
+describe("what a seat is holding", () => {
+ test("is the same number the felt plays with", () => {
+ const state = openedOnTheDefault();
+ state.seats.forEach((player, i) => {
+ if (i > 2) return;
+ assert.equal(HoldemState.chipsHeldBy(state, player), Betting.stackOf(state, player));
+ });
+ });
+
+ test("and is nothing at all for an empty chair", () => {
+ const state = openedOnTheDefault();
+ // Betting's own answer for an empty chair is the table default, because it is
+ // only ever asked about seats that are in a hand. The money side has to ask
+ // about all ten chairs, so it checks whether anybody is in them first.
+ assert.equal(Betting.stackOf(state, state.seats[5]), 200);
+ assert.equal(HoldemState.chipsHeldBy(state, state.seats[5]), 0);
+ });
+
+ test("counts a stack the dealer typed over the table's default", () => {
+ const state = openedOnTheDefault();
+ state.seats[0].stack = 75;
+ assert.equal(HoldemState.chipsHeldBy(state, state.seats[0]), 75);
+ });
+});
+
+describe("is anybody in this chair", () => {
+ const empty = () => HoldemState.createDefault().seats[0];
+
+ test("an untouched chair is empty", () => {
+ assert.equal(HoldemState.seatInPlay(empty()), false);
+ assert.equal(HoldemState.seatInPlay(null), false);
+ });
+
+ test("a name, chips, cash, a bet or a dealt hand all count", () => {
+ [
+ ["a name", p => { p.name = "Ann"; }],
+ ["chips", p => { p.stack = 50; }],
+ ["cash taken", p => { p.buyIns = 100; }],
+ ["a bet in front of them", p => { p.committed = 5; }],
+ ["money already swept in", p => { p.amountOverride = 20; }],
+ ["a hand dealt to them", p => { p.inHand = true; }],
+ ].forEach(([what, mark]) => {
+ const seat = empty();
+ mark(seat);
+ assert.equal(HoldemState.seatInPlay(seat), true, what);
+ });
+ });
+
+ test("whitespace is not a name", () => {
+ const seat = empty();
+ seat.name = " ";
+ assert.equal(HoldemState.seatInPlay(seat), false);
+ });
+});
+
+describe("the parts that used to disagree", () => {
+ test("the open seats and the seat count see the same empty chairs", () => {
+ const state = openedOnTheDefault();
+ // Three players, six chairs.
+ assert.deepEqual(HoldemState.openSeats(state), [3, 4, 5]);
+ const occupied = state.seats.filter(p => HoldemState.seatInPlay(p)).length;
+ assert.equal(occupied + HoldemState.openSeats(state).length, state.seats.length);
+ });
+
+ test("a chair holding only a dealt hand is not offered to the waiting list", () => {
+ // The old test looked at name, buy-ins and stack. A seat marked in-hand with
+ // none of those would have been handed to the next person in the queue, on top
+ // of whoever was already playing it.
+ const state = openedOnTheDefault();
+ state.seats[4].inHand = true;
+ assert.deepEqual(HoldemState.openSeats(state), [3, 5]);
+
+ HoldemState.addToWaitingList(state, "Priya");
+ const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 4);
+ assert.equal(result.ok, false);
+ });
+
+ test("the bank, the standings and the settle-up all price the night the same way", () => {
+ const state = openedOnTheDefault();
+ state.seats[0].stack = 320; // Ann is up
+ state.seats[1].stack = 80; // Ben is down
+
+ const bank = HoldemState.bankReconciliation(state);
+ const standings = HoldemState.sessionStandings(state);
+ const chipsFromStandings = standings
+ .filter(row => !row.cashedOut)
+ .reduce((sum, row) => sum + row.chipsInFront, 0);
+
+ assert.equal(bank.chipsInPlay, chipsFromStandings, "same chips on the table");
+ assert.equal(bank.balanced, true);
+
+ // And the payments the dealer is told to make add up to the same money.
+ const plan = SettleUp.plan(standings);
+ const owed = standings.reduce((sum, row) => sum + Math.max(0, row.cashPosition), 0);
+ const paid = plan.payments.reduce((sum, row) => sum + row.amount, 0);
+ assert.equal(paid + (plan.shortBy || 0) - (plan.overBy || 0), owed);
+ });
+
+ test("a player still holding the table's starting stack is in all three", () => {
+ const state = openedOnTheDefault();
+ const standings = HoldemState.sessionStandings(state);
+ assert.equal(standings.length, 3);
+ assert.deepEqual(standings.map(row => row.chipsInFront), [200, 200, 200]);
+ assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
+ assert.deepEqual(SettleUp.plan(standings).payments, [], "everyone is even, so nobody pays anybody");
+ });
+});
From 4db6f6e4d32f9ab558749e036a71e6ebbb56071c Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 09:21:19 -0600
Subject: [PATCH 53/55] Take the chips the pot was priced with, not just pay
them out
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two rounds of asking where the tool disagrees with itself, and here is the last
of it — the one that was actually creating money.
There are two ways a contribution gets in. Tapping the action moves chips: the
bet leaves the stack, sits in front of the player, and is swept into their hand
total. The other is the "without live betting" panel — a base pot amount, or
chips counted out on a seat — for a dealer recording a hand they are watching
rather than driving. Nothing leaves anybody's stack there.
The pot wizard prices both the same way, and awarding it credits the winner. So
in the second mode the table simply grew. Measured at a three-handed table with a
$50 base pot: stacks 200, 200, 200 before, pot priced at $150, and afterwards
350, 200, 200 — six hundred dollars of chips became seven hundred and fifty, and
the bank check, newly able to see it, blamed a rebuy nobody had made.
So before the pot is pushed, whatever was assumed rather than taken comes off the
stacks that assumed it, and is recorded where a swept bet lives. Chips already
committed or swept are left alone — they have been paid once, and taking them
twice is the same bug the other way round. A base pot on top of a live bet
collects only the difference. Nobody is charged more than they are holding, and
an empty chair or a seat sitting out is charged nothing at all.
The same hand now ends 300, 150, 150: the table has the six hundred it started
with, and the bank check is balanced before, during and after.
13 new tests, with the already-paid test disabled on purpose to confirm five of
them catch the double charge. Walked through the UI in base-pot mode: pot badge
$150, wizard pricing three seats at $50 each, and afterwards the felt holding
exactly what it held before.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/betting.js | 41 ++++
.../wwwroot/js/casino/holdem/main.js | 5 +
tests/holdem/assumedContributions.test.js | 175 ++++++++++++++++++
3 files changed, 221 insertions(+)
create mode 100644 tests/holdem/assumedContributions.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 94aada2..6f4048b 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -459,6 +459,47 @@ const Betting = {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
+ // Chips the pot has been priced with but nobody has actually paid.
+ //
+ // Two ways a contribution gets into this tool. Tapping the action moves chips: the
+ // bet leaves the stack, sits in front of the player, and is swept into their hand
+ // total at the end of the street. The other way is the "without live betting" panel
+ // — a base pot amount, or chips counted out on a seat — where the dealer is
+ // recording a hand they are watching rather than driving, and nothing ever leaves
+ // anybody's stack.
+ //
+ // The pot wizard prices both the same way, and awarding it credits the winner. So
+ // in that second mode the table simply grew: measured at a three-handed table with
+ // a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
+ // left the bank check blaming a rebuy that never happened.
+ //
+ // So before the pot is pushed, whatever was assumed rather than taken comes off the
+ // stacks that assumed it. Chips already committed or swept are left alone; they
+ // have been paid once already, and taking them twice is the same bug the other way
+ // round.
+ collectAssumedContributions(state) {
+ const taken = [];
+ state.seats.forEach((player, index) => {
+ const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
+ const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
+ const owed = this.round(priced - alreadyPaid);
+ if (owed <= this.EPSILON_CHIPS) return;
+
+ // Never more than they are holding. A seat priced above its own stack is a
+ // dealer's typo or a player who is short, and inventing the difference is
+ // what this whole function exists to stop.
+ const moved = this.round(Math.min(owed, this.stackOf(state, player)));
+ if (moved <= this.EPSILON_CHIPS) return;
+
+ player.stack = this.round(this.stackOf(state, player) - moved);
+ // Recorded where a swept bet lives, so the money is in exactly the place
+ // the rest of the tool looks for chips that are in the middle this hand.
+ player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
+ taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
+ });
+ return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
+ },
+
// A misdeal: the hand never happened, so every chip goes back where it came from.
//
// The rulings sheet in this tool has told dealers to redeal since the day it was
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 667a39a..d7555ac 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -261,6 +261,11 @@ document.addEventListener("DOMContentLoaded", () => {
if (rake > 0) {
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
}
+ // Anything the pot was priced with but nobody actually paid — the
+ // base pot and counted chips of the no-live-betting flow — comes off
+ // the stacks that owe it before the winner is paid. Otherwise the
+ // table gains the whole pot out of nowhere.
+ Betting.collectAssumedContributions(state);
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts, rake);
// Winnings go straight back in front of the player, so the next
diff --git a/tests/holdem/assumedContributions.test.js b/tests/holdem/assumedContributions.test.js
new file mode 100644
index 0000000..cba7a5d
--- /dev/null
+++ b/tests/holdem/assumedContributions.test.js
@@ -0,0 +1,175 @@
+// Chips the pot was priced with that nobody actually paid.
+//
+// There are two ways a contribution gets into this tool. Tapping the action moves
+// chips: the bet leaves the stack, sits in front of the player, and is swept into their
+// hand total at the end of the street. The other is the "without live betting" panel —
+// a base pot amount, or chips counted out on a seat — where the dealer is recording a
+// hand they are watching rather than driving, and nothing ever leaves a stack.
+//
+// The pot wizard prices both the same way and awarding it credits the winner, so in
+// that second mode the table simply grew.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
+
+const basePotTable = (base = 50, stack = 200) => {
+ const state = HoldemState.createDefault();
+ HoldemState.resizeSeats(state, 4);
+ state.basePotAmount = base;
+ ["Ann", "Ben", "Cal"].forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].inHand = true;
+ state.seats[i].stack = stack;
+ state.seats[i].buyIns = stack;
+ });
+ return state;
+};
+
+const pricedPot = (state) => state.seats.reduce(
+ (sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
+
+describe("what nobody paid", () => {
+ test("comes off the stacks that owe it", () => {
+ const state = basePotTable();
+ const collected = Betting.collectAssumedContributions(state);
+ assert.equal(collected.total, 150);
+ assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
+ assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
+ });
+
+ test("and lands where the rest of the tool looks for chips in the middle", () => {
+ const state = basePotTable();
+ Betting.collectAssumedContributions(state);
+ assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
+ assert.equal(HoldemState.unresolvedContributions(state), 150);
+ });
+
+ test("chips counted out on a seat are money too", () => {
+ const state = basePotTable(0);
+ state.seats[0].chipCounts = { red: 4 }; // four $5 chips
+ const collected = Betting.collectAssumedContributions(state);
+ assert.equal(collected.total, 20);
+ assert.equal(state.seats[0].stack, 180);
+ });
+});
+
+describe("what has already been paid", () => {
+ test("a live bet is left exactly alone", () => {
+ const state = basePotTable(0);
+ state.smallBlind = 5;
+ state.bigBlind = 10;
+ Betting.beginHand(state);
+ const before = state.seats.map(p => Betting.stackOf(state, p));
+
+ const collected = Betting.collectAssumedContributions(state);
+ assert.equal(collected.total, 0, "the blinds are already off their stacks");
+ assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
+ });
+
+ test("nor is a swept street taken a second time", () => {
+ const state = basePotTable(0);
+ state.smallBlind = 5;
+ state.bigBlind = 10;
+ Betting.beginHand(state);
+ Betting.sweepToHandTotals(state);
+ const before = state.seats.map(p => Betting.stackOf(state, p));
+
+ assert.equal(Betting.collectAssumedContributions(state).total, 0);
+ assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
+ });
+
+ test("a base pot on top of a live bet only collects the difference", () => {
+ // The base pot says "assume everyone is in for fifty". Somebody who has already
+ // put twenty in owes thirty of it, not fifty.
+ const state = basePotTable(50);
+ state.seats[0].committed = 20;
+ state.seats[0].stack = 180;
+ const collected = Betting.collectAssumedContributions(state);
+ const ann = collected.taken.find(row => row.name === "Ann");
+ assert.equal(ann.amount, 30);
+ assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
+ });
+});
+
+describe("what it will not do", () => {
+ test("take more than a player is holding", () => {
+ const state = basePotTable(50);
+ state.seats[1].stack = 20;
+ Betting.collectAssumedContributions(state);
+ assert.equal(state.seats[1].stack, 0, "all of it, and no further");
+ assert.equal(state.seats[1].amountOverride, 20);
+ });
+
+ test("touch an empty chair", () => {
+ const state = basePotTable();
+ const before = { ...state.seats[3] };
+ Betting.collectAssumedContributions(state);
+ assert.equal(state.seats[3].stack, before.stack);
+ assert.equal(state.seats[3].amountOverride, before.amountOverride);
+ });
+
+ test("or a seat that isn't in the hand", () => {
+ // The base pot stands in for "everyone put the same in", and somebody sitting
+ // out did not.
+ const state = basePotTable();
+ state.seats[2].inHand = false;
+ const collected = Betting.collectAssumedContributions(state);
+ assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
+ assert.equal(state.seats[2].stack, 200);
+ });
+});
+
+describe("the whole hand", () => {
+ test("conserves the chips on the table, which it did not before", () => {
+ const state = basePotTable();
+ const total = chipsOnTable(state);
+ const pot = pricedPot(state);
+
+ Betting.collectAssumedContributions(state);
+ Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
+ HoldemState.startNextHand(state);
+
+ assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
+ assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+
+ test("and the bank check stays balanced all the way through", () => {
+ const state = basePotTable();
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ Betting.collectAssumedContributions(state);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
+ Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
+ HoldemState.startNextHand(state);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+
+ test("a live-betting hand is unchanged by any of this", () => {
+ const state = basePotTable(0);
+ state.smallBlind = 5;
+ state.bigBlind = 10;
+ const total = chipsOnTable(state);
+ Betting.beginHand(state);
+ [3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
+ Betting.sweepToHandTotals(state);
+ const pot = HoldemState.unresolvedContributions(state);
+
+ Betting.collectAssumedContributions(state);
+ Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
+ HoldemState.startNextHand(state);
+
+ assert.equal(chipsOnTable(state), total);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+
+ test("collecting twice takes nothing the second time", () => {
+ // The pot wizard can be opened, cancelled and opened again.
+ const state = basePotTable();
+ Betting.collectAssumedContributions(state);
+ const stacks = state.seats.map(p => p.stack);
+ const again = Betting.collectAssumedContributions(state);
+ assert.equal(again.total, 0);
+ assert.deepEqual(state.seats.map(p => p.stack), stacks);
+ });
+});
From 8616636212142f64b681db1a8630b9afd96788b1 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 09:36:51 -0600
Subject: [PATCH 54/55] Make hidden mean hidden, and check that it does
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three rounds inside the money, so this one was a look at the thing. On a desktop
screenshot of a table mid-session there is a small empty box under the bank
check: 21 pixels wide, 44 tall, no text, no purpose. It is the "Take $600 from 3
players" button from three rounds ago, which sets `hidden` when there is nothing
to record — and `hidden` was losing.
The attribute carries its `display: none` from the browser's own stylesheet,
which is the weakest rule in the cascade, so any class that sets a display beats
it. Every button class here sets `inline-flex`. This is not a new discovery in
this file either: seven overlays already carry their own `[hidden] { display:
none }`, each one presumably found the same way. One global rule now covers all
of them, so there is no ninth.
The audit had been telling me. Its touch check reported the box as "could not be
probed (drawn 21x44)" — in the warnings, which my sweeps printed only sometimes
and never failed on. So the check now says the plainer thing: an element that
claims to be hidden and is drawn anyway is a fault, not a note. It is in runAll
with the rest, and the bug was put back by hand to confirm it fires.
The same warning stream had a second thing in it: the waiting list's Add button
was 43px wide. Three letters, one pixel under the rule, and a thumb doesn't know
the word is short.
182 audits across 13 viewports, both input kinds, every overlay and a table with
a rake, a cash-out, a waiting list and six logged hands on it: clean, and the
only note left is the one that says the 44px rule stood itself down on a mouse.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 17 +++++++++++++++++
tests/holdem/uiAudit.js | 21 +++++++++++++++++++++
2 files changed, 38 insertions(+)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 5c28d01..99a20dc 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -318,6 +318,20 @@ button,
margin-top: 1rem;
}
+/* `hidden` means hidden.
+
+ The attribute only carries `display: none` from the browser's own stylesheet, which
+ is the weakest rule there is: any class that sets a display — and every button here
+ sets `inline-flex` — beats it. That is not a hypothetical. Seven overlays in this
+ file already carry their own `[hidden] { display: none }` because each one was found
+ the hard way, and the eighth was a button that shipped as a 21x44 empty box under the
+ bank check on every table that didn't need it.
+
+ One rule, for everything, so there is no ninth. */
+[hidden] {
+ display: none !important;
+}
+
/* ---------- Poker table ---------- */
#pokerTableWrap {
@@ -2338,6 +2352,9 @@ body.hasActionBar #holdemSidebar {
.waitingAdd .holdemBtn {
flex: none;
min-height: 44px;
+ /* "Add" is three letters, which came out 43px wide — one pixel under the rule, and
+ the audit called it. A thumb doesn't know the word is short. */
+ min-width: 56px;
}
.waitingList {
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index ab5dd96..4a9adb1 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -719,6 +719,26 @@ const LayoutAudit = {
return { name: "the felt shows one dealer button", offenders };
},
+ // Anything that says it is hidden and is drawn anyway.
+ //
+ // `hidden` gets its `display: none` from the browser's own stylesheet, the weakest
+ // rule in the cascade, so any class that sets a display beats it — and in this tool
+ // every button class does. The result is an element the code believes is gone,
+ // sitting on the screen: found as a 21x44 empty box under the bank check, and this
+ // file's own touch check had been calling it "could not be probed" rather than
+ // saying the plainer thing, which is that it should not be there at all.
+ hiddenButPainted() {
+ const offenders = [...document.querySelectorAll("[hidden]")]
+ .filter(el => {
+ const rect = el.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return false;
+ const style = getComputedStyle(el);
+ return style.display !== "none" && style.visibility !== "hidden";
+ })
+ .map(el => `${this.describe(el)} :: hidden, but drawn ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`);
+ return { name: "hidden means hidden", offenders };
+ },
+
// ---- Runner ----
// Every check this file knows how to make, every time.
@@ -747,6 +767,7 @@ const LayoutAudit = {
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
+ this.hiddenButPainted(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];
From c4e842f3e3c2ad81962a10a5bc87b661fc05f897 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Wed, 2 Sep 2026 09:52:00 -0600
Subject: [PATCH 55/55] Stop the button's chip sitting on top of the name
underneath it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A phone screenshot, mid-hand, eight-handed: the seat with the button reads
"Christop" and then a white D. The name is centred across the whole puck and the
chip sits in its top-right corner on a layer above, so a long name runs
underneath and stops — measured, "Christopher" lost 20px of its 64px box.
The bad part is the silence. The text believed it had the room it was being
painted out of, so no ellipsis appeared: nothing on screen said the name went on.
On the one seat at the table a dealer looks at most.
The name now keeps out of the chip's corner on the seat that has the button, so a
long one ends in an ellipsis before it gets there. Only that seat — every other
puck has its full width, because the chip on those is invisible.
And the audit has a check for it now. Nothing it had would have caught this: the
occlusion check is about controls a thumb has to reach, and this is a label a
dealer has to read. The new one measures every seat's name against its own chip
and badges, and the fix was undone by hand to confirm it fires — "20px under
button.dealerChip.active".
182 audits across 13 viewports, both input kinds, plain and straddled tables,
every overlay: clean.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 8 ++++
.../wwwroot/js/casino/holdem/render.js | 6 +++
tests/holdem/uiAudit.js | 39 +++++++++++++++++++
3 files changed, 53 insertions(+)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 99a20dc..2318c8d 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -622,6 +622,14 @@ button,
margin-top: 0.6rem;
}
+/* The button's chip sits in this puck's top-right corner, on a layer above the text.
+ Reserving its width means a long name ends in an ellipsis before it reaches the
+ chip, rather than disappearing underneath one — on the one seat at the table a
+ dealer looks at most. */
+.seat.seatHasButton .seatPuckName {
+ padding-right: 22px;
+}
+
.seatPuckCards {
font-size: 0.68rem;
font-weight: 700;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 25607fd..d275926 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -408,6 +408,12 @@ const Render = {
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
+ // Marked on the seat as well as the chip, so the puck's own text can keep
+ // out from under it. A name is centred across the whole puck and the chip
+ // sits in the corner on a layer above: measured, "Christopher" on the
+ // button lost 20px of its 64px box to the chip and showed no ellipsis,
+ // because the text believed it had the room it was painted out of.
+ node.classList.toggle("seatHasButton", hasButton);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 4a9adb1..aebb402 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -739,6 +739,44 @@ const LayoutAudit = {
return { name: "hidden means hidden", offenders };
},
+ // A seat's name, painted under the decoration on its own puck.
+ //
+ // The chip that marks the button sits in the puck's top-right corner on a layer
+ // above the text, and the name is centred across the whole width. A long one
+ // therefore ran underneath it and stopped — measured, "Christopher" on the button
+ // lost 20px of its 64px box and showed no ellipsis, because as far as the text was
+ // concerned it had all the room it needed. Nothing else here would have caught it:
+ // the occlusion check is about controls a thumb has to reach, and this is a label
+ // a dealer has to read.
+ seatLabelsClear() {
+ const offenders = [];
+ document.querySelectorAll(".seat").forEach(seat => {
+ const name = seat.querySelector(".seatPuckName");
+ if (!name || !name.textContent.trim()) return;
+ const box = name.getBoundingClientRect();
+ if (!box.width || !box.height) return;
+ // Padding is room the text has already been kept out of, so it is not
+ // somewhere the text can be covered.
+ const style = getComputedStyle(name);
+ const textLeft = box.left + parseFloat(style.paddingLeft || 0);
+ const textRight = box.right - parseFloat(style.paddingRight || 0);
+
+ seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
+ const over = mark.getBoundingClientRect();
+ if (!over.width || !over.height) return;
+ const marked = getComputedStyle(mark);
+ if (marked.display === "none" || marked.visibility === "hidden" ||
+ parseFloat(marked.opacity) < 0.05) return;
+ const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
+ const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
+ if (covered > 1 && vertical > 1) {
+ offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
+ }
+ });
+ });
+ return { name: "seat names aren't painted under their own badges", offenders };
+ },
+
// ---- Runner ----
// Every check this file knows how to make, every time.
@@ -768,6 +806,7 @@ const LayoutAudit = {
this.seatOwnership(),
this.oneDealerButton(),
this.hiddenButPainted(),
+ this.seatLabelsClear(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];