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