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.

+ +

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); + }); +});