Stop the tool from throwing a pot away without saying so

Two ways to lose a hand's money by accident, both found by walking the settle
flow and checking the chip count afterwards.

Next Hand destroyed an unresolved pot silently

Starting the next hand clears every seat's contribution, so money still in the
middle simply vanished. Measured at a four-handed table with $8 in the pot: the
table went from 800 chips to 792, and the toast said "Next hand — button on seat
3. Blinds posted." — not a word about the eight chips that had just left.

It now asks, names the amount, and says what will happen to it. Confirming still
works and the toast says what went. The undo was always there, but a dealer
can't undo something they were never told about.

The wizard's "Close" abandoned a payout that looked finished

After picking winners and confirming, the wizard shows "Final Payouts — Ann $8"
above two buttons: "Start Next Hand" and "Close". Close pays nobody. Under that
heading, with a name and an amount beside it, a dealer taps Close believing the
pot has been pushed — and walks away with the chips still in the middle and the
hand unlogged. The button now says "Cancel — don't pay yet", with a line
underneath spelling out that nothing has been paid.

The warning measures the right thing

The first version counted the pot badge, which was wrong in a way the tests
caught before it shipped: the table-wide base pot is a standing ante for dealers
who don't track stacks, and it re-applies to the next hand rather than being
lost. Quoting it would have put a confirmation in front of those dealers before
every single hand — a nag, on the simplest way to run a table, for money that
wasn't going anywhere.

HoldemState.unresolvedContributions() measures exactly the fields startNextHand
clears: banked contributions, chip counts, and whatever is still in front of
players on this street. Verified both ways in the browser — a base-pot table
showing a $100 pot is not asked at all, a stack-tracking table with $8 live is.

6 tests (115 total): the quoted number equals what actually leaves the table,
mid-street chips are included, a settled hand leaves nothing to warn about, five
settled hands conserve every chip, an empty table has no phantom pot, and a
standing ante is never warned about.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-01 04:39:51 -06:00
co-authored by Claude Opus 5
parent a488bbf832
commit cc6a1902bc
4 changed files with 175 additions and 7 deletions
+21 -2
View File
@@ -318,15 +318,34 @@ document.addEventListener("DOMContentLoaded", () => {
Render.renderAll(); Render.renderAll();
}); });
document.getElementById("nextHandBtn").addEventListener("click", () => { 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
// middle took the table from 800 chips to 792, and the toast said nothing
// about it. Chips only ever leave this tool because a dealer meant them to.
const unresolved = HoldemState.unresolvedContributions(state);
if (unresolved > Betting.EPSILON_CHIPS) {
const ok = await Dialog.confirm({
title: `Throw away ${Money.format(unresolved)}?`,
message: "There's still money in the middle from this hand. Starting the next one takes it off the table without paying anybody — use Resolve Pot if somebody won it.",
confirmLabel: "Throw it away",
danger: true,
});
if (!ok) return;
}
// startNextHand moves the button on by one; say where it landed so the // startNextHand moves the button on by one; say where it landed so the
// dealer can confirm the rotation without hunting for the badge. // dealer can confirm the rotation without hunting for the badge.
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1); const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
const where = state.dedicatedDealer const where = state.dedicatedDealer
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}` ? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`; : `button on seat ${nextDealer + 1}`;
const discarded = unresolved > Betting.EPSILON_CHIPS
? ` ${Money.format(unresolved)} taken off the table.`
: "";
runUndoable(`Next hand — ${where}. Blinds posted.`, () => { runUndoable(`Next hand — ${where}. Blinds posted.${discarded}`, () => {
HoldemState.startNextHand(state); HoldemState.startNextHand(state);
Betting.beginHand(state); Betting.beginHand(state);
}); });
@@ -272,11 +272,25 @@ const PotWizard = {
}); });
actions.appendChild(applyBtn); actions.appendChild(applyBtn);
const closeBtn = document.createElement("button"); // This button cancels — it does not pay anybody. Under a heading reading
closeBtn.className = "holdemBtn holdemBtnGhost"; // "Final Payouts" with an amount beside a name, "Close" reads like dismissing
closeBtn.textContent = "Close"; // something already done, and a dealer who taps it walks away believing the
closeBtn.addEventListener("click", () => this.close()); // pot has been pushed. It hasn't: the chips are still in the middle and the
actions.appendChild(closeBtn); // hand isn't logged. So it says what it does.
const cancelBtn = document.createElement("button");
cancelBtn.className = "holdemBtn holdemBtnGhost";
cancelBtn.textContent = "Cancel — don't pay yet";
cancelBtn.addEventListener("click", () => this.close());
actions.appendChild(cancelBtn);
if (this.totals.size > 0) {
const note = document.createElement("p");
note.className = "fieldHint potWizardPendingNote";
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
wrap.appendChild(actions);
wrap.appendChild(note);
return wrap;
}
wrap.appendChild(actions); wrap.appendChild(actions);
return wrap; return wrap;
@@ -167,6 +167,38 @@ const HoldemState = {
return !player.inHand; return !player.inHand;
}, },
// What starting the next hand would take off the table for good: exactly the
// fields startNextHand clears.
//
// Deliberately not totalPot(). The table-wide base pot is a standing ante — a
// stand-in for "everyone puts the same in every hand", used by a dealer who
// isn't tracking stacks — so it re-applies to the next hand rather than being
// lost with this one. Counting it would mean warning about money that isn't
// going anywhere, on every single hand, for exactly the dealers who chose the
// simplest way to run the table.
unresolvedContributions(state) {
const total = state.seats.reduce((sum, player) => {
const override = player.amountOverride;
let banked = 0;
if (override !== null && override !== undefined && String(override).trim() !== "") {
const parsed = Number(override);
if (!Number.isNaN(parsed)) banked = Math.max(0, parsed);
}
if (banked === 0) {
banked = (state.chipLegend || []).reduce((chips, chip) => {
const count = Number(player.chipCounts[chip.id]) || 0;
return chips + count * Number(chip.value || 0);
}, 0);
}
return sum + banked + Math.max(0, Number(player.committed) || 0);
}, 0);
return Money.round(total);
},
totalPot(state) { totalPot(state) {
return state.seats.reduce( return state.seats.reduce(
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount), (sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
+103
View File
@@ -0,0 +1,103 @@
// Chips only ever leave this tool because a dealer meant them to. Starting the next
// hand clears every seat's contribution, so an unresolved pot is simply destroyed —
// the UI now asks first, and the number it quotes has to be exactly what goes.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, Money, makeTable, chipsOnTable } = require("./load");
// The same measure the confirmation uses.
const unresolvedPot = state => HoldemState.unresolvedContributions(state);
describe("what starting the next hand destroys", () => {
test("the quoted amount is exactly what leaves the table", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
const before = chipsOnTable(state);
const quoted = unresolvedPot(state);
assert.ok(quoted > 0, "there is a pot to warn about");
HoldemState.startNextHand(state);
assert.equal(
Money.toCents(chipsOnTable(state)),
Money.toCents(before - quoted),
"the dealer must be told the real number, not an approximation"
);
});
test("chips still in front of players on the current street are counted too", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state); // blinds are `committed`, not yet banked
Betting.raiseTo(state, 0, 25); // and so is a raise mid-street
const before = chipsOnTable(state);
const quoted = unresolvedPot(state);
HoldemState.startNextHand(state);
assert.equal(Money.toCents(chipsOnTable(state)), Money.toCents(before - quoted));
assert.ok(quoted >= 25, `a live raise has to be included; quoted ${quoted}`);
});
test("a hand that was paid out properly leaves nothing to warn about", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
const pot = HoldemState.totalPot(state);
const payouts = [{ id: state.seats[0].id, label: "Ann", amount: pot }];
HoldemState.recordHand(state, payouts);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(unresolvedPot(state), 0, "nothing left over after a settled hand");
});
test("settling then dealing on conserves every chip", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
const start = chipsOnTable(state);
for (let hand = 0; hand < 5; hand++) {
Betting.beginHand(state);
const live = Betting.liveSeats(state);
live.forEach(s => { if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index); });
Betting.sweepToHandTotals(state);
const pot = HoldemState.totalPot(state);
const winner = Betting.liveSeats(state)[0];
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
HoldemState.recordHand(state, payouts);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), start, `chips drifted on hand ${hand + 1}`);
}
});
test("an empty table has no phantom pot to warn about", () => {
const state = makeTable({ seatCount: 6, seated: [] });
assert.equal(unresolvedPot(state), 0);
});
test("a standing ante is never warned about, because it isn't lost", () => {
// The table default is a stand-in for "everyone puts the same in every hand",
// used by a dealer not tracking stacks at all. It re-applies to the next hand
// rather than going anywhere, so counting it would nag this dealer before
// every single hand — which is why the warning measures the fields
// startNextHand actually clears rather than the pot badge.
const state = makeTable({ seatCount: 9, seated: [0, 1] });
state.basePotAmount = 25;
state.seats.forEach(p => { p.amountOverride = null; });
// The base pot carries into the next hand, so nothing is being thrown away
// and the dealer must not be asked to confirm anything.
assert.equal(HoldemState.totalPot(state), 50, "the badge still shows a pot");
assert.equal(unresolvedPot(state), 0, "but none of it is at risk, so no warning");
HoldemState.startNextHand(state);
assert.equal(HoldemState.totalPot(state), 50, "the standing ante applies to the new hand too");
});
});