diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index a44a751..c8d0e9d 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -110,10 +110,23 @@
In
Won
Net
+ Cash out
- Once you resolve a pot, everyone's running total for the night shows up here.
+ Everyone who has bought in shows up here, with what they'd be owed if the night ended now.
+
+
+
+ Cash taken
+
+
+
+ Chips in play
+
+
+
+
Hands
@@ -225,6 +238,7 @@
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 8687072..d6e46f1 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -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;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index eebe5b1..7956fd1 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -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 ----
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 43e2937..1cd4163 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -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) {
diff --git a/tests/holdem/bankAudit.test.js b/tests/holdem/bankAudit.test.js
new file mode 100644
index 0000000..eb2f2b2
--- /dev/null
+++ b/tests/holdem/bankAudit.test.js
@@ -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);
+ });
+});