Light up the seat that's holding the game up #57

Merged
jheaps merged 9 commits from feature/casino-holdem-acting-seat into master 2026-09-01 09:31:52 -06:00
5 changed files with 313 additions and 2 deletions
Showing only changes of commit 60d192a9ec - Show all commits
+15 -1
View File
@@ -110,10 +110,23 @@
<span>In</span>
<span>Won</span>
<span>Net</span>
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
</div>
<div id="standingsList"></div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
<div id="bankReconcile" class="bankReconcile" hidden>
<div class="bankReconcileRow">
<span>Cash taken</span>
<span id="bankCashIn"></span>
</div>
<div class="bankReconcileRow">
<span>Chips in play</span>
<span id="bankChipsInPlay"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
</div>
<h3 class="panelSubhead">Hands</h3>
<div id="handHistoryList"></div>
@@ -225,6 +238,7 @@
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsCashOut"></span>
</div>
</template>
+43 -1
View File
@@ -1467,7 +1467,7 @@ button,
.standingsRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
grid-template-columns: minmax(0, 1fr) auto auto auto auto;
gap: 0.5rem;
align-items: baseline;
padding: 0.3rem 0.1rem;
@@ -1481,6 +1481,48 @@ button,
min-width: 4.5rem;
}
/* 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. */
.standingsCashOut {
font-weight: 600;
}
/* ---------- Bank reconciliation ----------
Cash taken versus chips on the table. Every chip is a promise to pay cash for it
later, and if the chips outrun the box that promise can't be kept — which a dealer
otherwise discovers when the last two players cash out and there's nothing left. */
.bankReconcile {
margin-top: 0.6rem;
padding: 0.55rem 0.6rem;
border: 1px solid #2f2f2f;
border-radius: 8px;
background: #151515;
}
.bankReconcileRow {
display: flex;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.78rem;
color: #b8b8b8;
font-variant-numeric: tabular-nums;
padding: 0.12rem 0;
}
.bankReconcileVerdict {
margin: 0.4rem 0 0;
font-size: 0.74rem;
line-height: 1.35;
}
.bankReconcileVerdict.bankBalanced {
color: #7fc99a;
}
.bankReconcileVerdict.bankOff {
color: #f0b46a;
}
.standingsHeader {
color: #8a8a8a;
font-size: 0.7rem;
@@ -701,8 +701,46 @@ 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.
const cashEl = node.querySelector(".standingsCashOut");
cashEl.textContent = Money.formatSigned(row.cashPosition);
cashEl.classList.toggle("netUp", row.cashPosition > 0);
cashEl.classList.toggle("netDown", row.cashPosition < 0);
list.appendChild(node);
});
this.renderBankReconciliation();
},
// Does the cash box cover the chips on the table? Nothing asked this before, and
// it's the question that decides whether the last two players can be paid.
renderBankReconciliation() {
const host = document.getElementById("bankReconcile");
if (!host) return;
const bank = HoldemState.bankReconciliation(this.state);
// Nothing to reconcile before anybody has handed over money or taken a chip.
host.hidden = bank.cashIn === 0 && bank.chipsInPlay === 0;
if (host.hidden) return;
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
const verdict = document.getElementById("bankReconcileVerdict");
if (bank.balanced) {
verdict.textContent = "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.`;
} 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);
},
// ---- Hand counter + history ----
@@ -384,6 +384,23 @@ const HoldemState = {
// 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]));
// 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
// left a player who bought in and then sat through a few hands folding —
// or who just sat down — missing entirely, so the banker's own record didn't
// mention the cash they'd taken. They turn up at cash-out holding chips the
// list never accounted for.
state.seats.forEach((player, index) => {
if (totals.has(player.id)) return;
const hasMoneyInPlay =
(Number(player.buyIns) || 0) > 0 ||
(Number(player.stack) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
(Number(player.committed) || 0) > 0;
if (!hasMoneyInPlay) return;
totals.set(player.id, { id: player.id, name: this.displayName(player, index), in: 0, won: 0 });
});
return Array.from(totals.values())
.map(t => {
const seat = seatById.get(t.id);
@@ -408,6 +425,37 @@ const HoldemState = {
})
.sort((a, b) => b.net - a.net);
},
// The one check a banker actually needs, and the one nothing here did.
//
// Every chip on the table is a promise to pay cash for it later. If the chips in
// play add up to more than the cash that came in, that promise can't be kept —
// and the way you find out is the last two players cashing out and the box being
// empty. It's always someone taking chips without paying, a rebuy that never got
// recorded, or a miscount, and it's silent until it's too late to work out which.
//
// 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.
bankReconciliation(state) {
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
const chipsInPlay = state.seats.reduce(
(sum, p) =>
sum +
Math.max(0, Number(p.stack) || 0) +
Math.max(0, Number(p.committed) || 0) +
Math.max(0, Number(p.amountOverride) || 0),
0
);
const difference = Money.round(chipsInPlay - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
};
},
};
if (typeof module !== "undefined" && module.exports) {
+169
View File
@@ -0,0 +1,169 @@
// The banker's job, which is a different job from the dealer's: every chip on the
// table is a promise to pay cash for it later, and somebody has to be able to check
// that the promise can be kept before the last two players try to cash out.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, Money, makeTable } = require("./load");
const seat = (state, index, name, cash, chips) => {
const p = state.seats[index];
p.name = name; p.inHand = true; p.buyIns = cash; p.stack = chips;
return p;
};
describe("everyone with money in the game is accounted for", () => {
test("a player who has bought in but played no logged hand still appears", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 100, 100);
seat(state, 2, "Cal", 200, 200); // just sat down
state.seats[0].amountOverride = 10;
state.seats[1].amountOverride = 10;
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 20 }]);
state.seats[0].amountOverride = null;
state.seats[1].amountOverride = null;
const names = HoldemState.sessionStandings(state).map(r => r.name);
assert.ok(names.includes("Cal"), `Cal handed over $200 and is missing: ${names.join(", ")}`);
});
test("their row reads as no hands played, not as a loss", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 200, 200);
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Ben");
assert.equal(cal.in, 0);
assert.equal(cal.won, 0);
assert.equal(cal.net, 0);
assert.equal(cal.boughtIn, 200);
assert.equal(cal.cashPosition, 0, "level: they've bought chips and still hold them");
});
test("an empty chair is not dragged into the settle-up", () => {
const state = makeTable({ seatCount: 9, seated: [0], stack: 0 });
seat(state, 0, "Ann", 100, 100);
const rows = HoldemState.sessionStandings(state);
assert.equal(rows.length, 1);
});
test("somebody who has cashed out completely still shows what they took", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 100, 0); // handed every chip back
seat(state, 1, "Ben", 100, 200);
const ann = HoldemState.sessionStandings(state).find(r => r.name === "Ann");
assert.equal(ann.cashPosition, -100, "down the whole buy-in");
});
});
describe("does the cash box cover the chips on the table", () => {
test("a straight game balances", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
seat(state, 0, "Ann", 100, 60);
seat(state, 1, "Ben", 100, 140);
seat(state, 2, "Cal", 100, 100);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.cashIn, 300);
assert.equal(bank.chipsInPlay, 300);
assert.equal(bank.difference, 0);
assert.equal(bank.balanced, true);
});
test("chips outrunning the cash box is caught before cash-out", () => {
// The 2am failure: the last players out can't be paid.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 100, 100);
seat(state, 2, "Cal", 100, 260); // took chips for a rebuy nobody wrote down
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.cashIn, 300);
assert.equal(bank.chipsInPlay, 460);
assert.equal(bank.difference, 160);
assert.equal(bank.balanced, false);
});
test("cash taken for chips never handed over is caught too", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 200, 100); // paid 200, only got 100 in chips
seat(state, 1, "Ben", 100, 100);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.difference, -100);
assert.equal(bank.balanced, false);
});
test("chips in the middle still count as chips in play", () => {
// Money in the pot is somebody's; it just hasn't been won yet. Leaving it out
// would report a shortfall every time a hand was open — which is nearly always.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
const before = HoldemState.bankReconciliation(state);
assert.equal(before.balanced, true);
Betting.beginHand(state); // blinds go in
Betting.raiseTo(state, 0, 25); // and a raise
Betting.sweepToHandTotals(state); // banked into the pot
const during = HoldemState.bankReconciliation(state);
assert.equal(during.balanced, true, "an open pot is not a discrepancy");
assert.equal(during.chipsInPlay, before.chipsInPlay);
});
test("a whole session of play never knocks the books out", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
for (let hand = 0; hand < 6; hand++) {
Betting.beginHand(state);
Betting.liveSeats(state).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(HoldemState.bankReconciliation(state).balanced, true, `books off after hand ${hand + 1}`);
}
});
test("a rebuy keeps the books straight, a chip grab doesn't", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 100, 100);
// A proper rebuy: cash to the banker, chips to the player.
state.seats[0].buyIns += 100;
state.seats[0].stack += 100;
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
// Chips taken without paying.
state.seats[1].stack += 50;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, 50);
});
test("nothing to reconcile before anybody has bought in", () => {
const state = makeTable({ seatCount: 6, seated: [], stack: 0 });
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.cashIn, 0);
assert.equal(bank.chipsInPlay, 0);
assert.equal(bank.balanced, true);
});
test("float dust is not a discrepancy", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 0.1 + 0.2, 0.3);
seat(state, 1, "Ben", 10, 10);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});