Ran out of things I could find by auditing the UI, so I went and read what people
who run home games actually complain about. The recurring one isn't a dealing
mistake, it's a banking one: "the last two guys go to cash out at the end of some
all-night game and there isn't enough cash to cover the chips they have."
Every chip on the table is a promise to pay cash for it later. Nothing in here
checked that the promise could be kept, and there's no way to notice by eye —
it's always a rebuy nobody wrote down, or chips taken without paying, and it
stays silent until the box is empty and two people are still holding chips.
The books now add up, or say why not
HoldemState.bankReconciliation compares the cash taken in against every chip that
could be handed back — stacks, chips in front of players, and the pot waiting to
be won. The sidebar shows both numbers and what they mean:
Balanced — the cash covers every chip on the table.
$160 more in chips than cash taken. Someone's rebuy probably wasn't
recorded — the box will be short at cash-out.
$150 more cash than chips in play. Someone has paid for chips they
haven't been given.
Money in the middle counts as chips in play, so an open pot isn't reported as a
shortfall — otherwise it would cry wolf during almost every hand.
Players who had bought in were missing from the settle-up
sessionStandings was built from the hand log, so anybody who hadn't yet been in a
recorded hand didn't appear at all. Somebody hands over $200, sits down, folds a
few, and the banker's own record doesn't mention them — then they turn up at
cash-out holding chips the list never accounted for. Anyone with money in the
game is in the list now, whether or not they've played a logged hand.
The settle-up number was already being computed and hidden
cashPosition — chips in front of them less the cash they handed over — was
calculated for every player and written to the CSV, but never shown on screen.
It's the number you read while counting out cash at the end of the night, so it's
a column now.
15 tests (127 total): a buy-in with no hands played still appears and reads as
level rather than a loss, empty chairs stay out, a player who cashed out shows
what they took, both directions of imbalance are caught, an open pot is not a
discrepancy, six played-and-settled hands never knock the books out, a proper
rebuy balances while a chip grab doesn't, and float dust isn't an alarm.
Validated with Playwright through real play — seated, blinds posted, pot open,
hand settled, rebuy — balanced at every step, plus the three seeded verdicts and
a fresh table where the panel stays hidden.
Sources: pokerlistings.com/blog/how-to-run-a-home-poker-game,
chipsoffury.com/learn/how-to-handle-poker-buy-ins-with-friends/
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
464 lines
20 KiB
JavaScript
464 lines
20 KiB
JavaScript
// Table state: seats, chips, blinds, notes. Persisted to localStorage so a dealer's
|
|
// browser refresh mid-game doesn't lose the table.
|
|
const HoldemState = {
|
|
STORAGE_KEY: "casino.holdem.state.v1",
|
|
DEFAULT_SEAT_COUNT: 10,
|
|
|
|
DEFAULT_CHIP_LEGEND: [
|
|
{ id: "white", color: "#f5f5f5", value: 1 },
|
|
{ id: "red", color: "#c0392b", value: 5 },
|
|
{ id: "green", color: "#1e8449", value: 25 },
|
|
{ id: "black", color: "#111111", value: 100 },
|
|
{ id: "purple", color: "#6c3483", value: 500 },
|
|
],
|
|
|
|
makeId(prefix) {
|
|
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
},
|
|
|
|
makePlayer() {
|
|
return {
|
|
id: this.makeId("player"),
|
|
name: "",
|
|
inHand: false,
|
|
folded: false,
|
|
cards: ["", ""],
|
|
chipCounts: {},
|
|
amountOverride: null,
|
|
customFields: [],
|
|
// Live-betting fields: chips in front of the player, chips they've put in
|
|
// on the current street, and whether they've answered the current bet.
|
|
stack: null,
|
|
committed: 0,
|
|
hasActed: false,
|
|
// Cash handed over tonight, which only moves when they buy chips.
|
|
buyIns: 0,
|
|
};
|
|
},
|
|
|
|
MAX_HISTORY_ENTRIES: 200,
|
|
|
|
createDefault() {
|
|
const seatCount = this.DEFAULT_SEAT_COUNT;
|
|
return {
|
|
seatCount,
|
|
direction: "clockwise",
|
|
basePotAmount: null,
|
|
dealerSeatIndex: 0,
|
|
dedicatedDealer: false,
|
|
chipLegend: this.DEFAULT_CHIP_LEGEND.map(c => ({ ...c })),
|
|
seats: Array.from({ length: seatCount }, () => this.makePlayer()),
|
|
notes: "",
|
|
handNumber: 1,
|
|
handHistory: [],
|
|
smallBlind: 1,
|
|
bigBlind: 2,
|
|
defaultStack: null,
|
|
street: "preflop",
|
|
currentBet: 0,
|
|
lastRaiseSize: 0,
|
|
actingSeatIndex: null,
|
|
// Set when the dealer names the blinds by hand instead of letting them
|
|
// follow the button — { sbIndex, bbIndex }. Home games do this constantly
|
|
// ("you had it last time", someone arriving late owing a blind), and the
|
|
// button order is the wrong answer often enough that it has to be
|
|
// overridable. Cleared at the start of every hand.
|
|
blindOverride: null,
|
|
};
|
|
},
|
|
|
|
load() {
|
|
try {
|
|
const raw = localStorage.getItem(this.STORAGE_KEY);
|
|
if (!raw) return this.createDefault();
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
|
|
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
|
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
|
|
|
// Tables saved before live betting existed are missing its fields; fill
|
|
// them in rather than making the dealer reset a table mid-session.
|
|
const defaults = this.createDefault();
|
|
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
|
"actingSeatIndex", "blindOverride"]
|
|
.forEach(key => {
|
|
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
|
});
|
|
parsed.seats.forEach(player => {
|
|
if (player.stack === undefined) player.stack = null;
|
|
if (player.committed === undefined) player.committed = 0;
|
|
if (player.hasActed === undefined) player.hasActed = false;
|
|
if (player.buyIns === undefined) player.buyIns = 0;
|
|
});
|
|
|
|
return parsed;
|
|
} catch {
|
|
return this.createDefault();
|
|
}
|
|
},
|
|
|
|
save(state) {
|
|
try {
|
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
|
} catch {
|
|
// Storage full/unavailable (private browsing, etc.) — the table still works
|
|
// for the rest of the session, it just won't survive a refresh.
|
|
}
|
|
},
|
|
|
|
clear() {
|
|
try {
|
|
localStorage.removeItem(this.STORAGE_KEY);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
},
|
|
|
|
resizeSeats(state, newCount) {
|
|
const count = Math.max(2, Math.min(24, Math.floor(newCount) || this.DEFAULT_SEAT_COUNT));
|
|
const seats = state.seats.slice(0, count);
|
|
while (seats.length < count) seats.push(this.makePlayer());
|
|
state.seats = seats;
|
|
state.seatCount = count;
|
|
if (state.dealerSeatIndex >= count) state.dealerSeatIndex = 0;
|
|
return state;
|
|
},
|
|
|
|
// What to call a seat in logs, payouts and standings when the dealer hasn't
|
|
// typed a name in yet.
|
|
displayName(player, seatIndex) {
|
|
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
|
|
},
|
|
|
|
// Contribution the table calculator will treat this player as having put in.
|
|
//
|
|
// Chips already pushed into the middle count whether or not the player is still
|
|
// in the hand. Someone who storms off, gets taken out by the dealer, or is
|
|
// dealt out mid-hand leaves their money on the felt — it's dead money owed to
|
|
// whoever wins, and it is not theirs to take back. Reading `inHand` first is
|
|
// how those chips used to disappear out of the pot the instant a seat was
|
|
// switched off, quietly shrinking the payout by whatever they had posted.
|
|
computeContribution(player, chipLegend, basePotAmount) {
|
|
const override = player.amountOverride;
|
|
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
|
const parsed = Number(override);
|
|
if (!Number.isNaN(parsed)) return Math.max(0, parsed);
|
|
}
|
|
|
|
const chipTotal = chipLegend.reduce((sum, chip) => {
|
|
const count = Number(player.chipCounts[chip.id]) || 0;
|
|
return sum + count * Number(chip.value || 0);
|
|
}, 0);
|
|
if (chipTotal > 0) return chipTotal;
|
|
|
|
// The table-wide default is the one thing that stays tied to being dealt in:
|
|
// it's a stand-in for "everyone put the same amount in", so applying it to
|
|
// empty chairs would invent a contribution from every seat nobody is using.
|
|
if (!player.inHand) return 0;
|
|
|
|
const base = Number(basePotAmount);
|
|
return Number.isNaN(base) ? 0 : Math.max(0, base);
|
|
},
|
|
|
|
// Anyone whose chips are in the pot, whether or not they're still in the hand.
|
|
// A seat that's been switched off mid-hand is dead money: it counts towards the
|
|
// pot but can never be picked as a winner, exactly like a fold.
|
|
isDeadMoney(player) {
|
|
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) {
|
|
return state.seats.reduce(
|
|
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
|
|
0
|
|
);
|
|
},
|
|
|
|
// Next occupied-by-index seat in the current blind direction. Every seat index
|
|
// counts (there's no separate "empty chair" concept) — a dealer running fewer
|
|
// players just keeps the seat count matched to who's actually sitting down.
|
|
nextSeatIndex(state, fromIndex, step = 1) {
|
|
const count = state.seatCount;
|
|
const delta = state.direction === "counterclockwise" ? -step : step;
|
|
return ((fromIndex + delta) % count + count) % count;
|
|
},
|
|
|
|
// The button passes to the next player, not the next chair. Moving it one seat at
|
|
// a time parks it on empty chairs — with four players spread around ten seats it
|
|
// sits on nobody most hands — and that isn't only untidy: position and blinds are
|
|
// supposed to come round to each player at the same rate, and they don't if the
|
|
// button is rotating past chairs nobody is sitting in.
|
|
advanceButton(state) {
|
|
for (let step = 1; step <= state.seatCount; step++) {
|
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
|
if (state.seats[index].inHand) {
|
|
state.dealerSeatIndex = index;
|
|
return;
|
|
}
|
|
}
|
|
// Nobody is dealt in; move it along anyway so it doesn't look stuck.
|
|
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
|
},
|
|
|
|
// Seats dealt into this hand, in blind order starting from the seat after the
|
|
// button. Blinds follow the players, not the chairs — the seats immediately left
|
|
// of the button are empty often enough that counting chairs posts them to nobody.
|
|
liveOrderFromButton(state) {
|
|
const order = [];
|
|
for (let step = 1; step <= state.seatCount; step++) {
|
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
|
if (state.seats[index].inHand) order.push(index);
|
|
}
|
|
return order;
|
|
},
|
|
|
|
// A button sitting on an empty chair is only cosmetic until you try to read
|
|
// position off it. On the very first hand of a fresh table it starts on seat 1
|
|
// whether or not anybody is sitting there, and a table that empties out around it
|
|
// leaves it stranded. Move it to a real player before a hand is dealt.
|
|
ensureButtonOnPlayer(state) {
|
|
if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return;
|
|
for (let step = 0; step < state.seatCount; step++) {
|
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
|
if (state.seats[index].inHand) {
|
|
state.dealerSeatIndex = index;
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
|
|
// A hand-picked blind is only usable if that seat is actually in the hand — a
|
|
// dealer who names a blind and then deals that player out shouldn't have the
|
|
// table quietly post to nobody.
|
|
validBlindOverride(state) {
|
|
const o = state.blindOverride;
|
|
if (!o) return null;
|
|
const ok = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i] && state.seats[i].inHand;
|
|
if (!ok(o.sbIndex) || !ok(o.bbIndex) || o.sbIndex === o.bbIndex) return null;
|
|
return o;
|
|
},
|
|
|
|
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
|
// small blind, which is the opposite of the usual order.
|
|
getBlindSeats(state) {
|
|
const live = this.liveOrderFromButton(state);
|
|
const dealerIndex = state.dealerSeatIndex;
|
|
|
|
// A blind the dealer named by hand wins over anything positional. Heads-up is
|
|
// still reported honestly so the betting order stays right — who posts what
|
|
// is the dealer's call, but two-handed play acting in reverse is a rule.
|
|
const override = this.validBlindOverride(state);
|
|
if (override) {
|
|
return {
|
|
dealerIndex,
|
|
sbIndex: override.sbIndex,
|
|
bbIndex: override.bbIndex,
|
|
headsUp: live.length === 2,
|
|
manual: true,
|
|
};
|
|
}
|
|
|
|
if (live.length < 2) {
|
|
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
|
|
}
|
|
|
|
if (live.length === 2) {
|
|
const buttonIsPlaying = state.seats[dealerIndex].inHand;
|
|
const sbIndex = buttonIsPlaying ? dealerIndex : live[0];
|
|
const bbIndex = live.find(i => i !== sbIndex);
|
|
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
|
|
}
|
|
|
|
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
|
|
},
|
|
|
|
startNextHand(state) {
|
|
this.advanceButton(state);
|
|
// A hand-picked blind applies to the hand it was picked for. Carrying it into
|
|
// the next one would freeze the blinds on two players for the rest of the
|
|
// night, which is the opposite of what naming them once is for.
|
|
state.blindOverride = null;
|
|
state.seats.forEach(p => {
|
|
p.folded = false;
|
|
p.cards = ["", ""];
|
|
p.chipCounts = {};
|
|
p.amountOverride = null;
|
|
p.committed = 0;
|
|
p.hasActed = false;
|
|
});
|
|
// Anyone who joined mid-hand is a full player again from here.
|
|
this.ensureButtonOnPlayer(state);
|
|
},
|
|
|
|
// Appends a resolved-pot record to the running session log a dealer can point to
|
|
// for chip-count disputes, then advances the hand counter. `payouts` is
|
|
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
|
|
//
|
|
// Must be called before startNextHand() clears the seats: the per-player `ledger`
|
|
// it stores alongside the payouts is what session standings are summed from later,
|
|
// and it needs this hand's contributions while they're still on the table.
|
|
recordHand(state, payouts) {
|
|
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
|
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
|
|
|
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
|
|
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
|
|
const ledger = state.seats
|
|
.map((player, index) => ({
|
|
id: player.id,
|
|
name: this.displayName(player, index),
|
|
in: this.computeContribution(player, state.chipLegend, state.basePotAmount),
|
|
won: wonById.get(player.id) || 0,
|
|
}))
|
|
.filter(row => row.in > 0 || row.won > 0);
|
|
|
|
state.handHistory.unshift({
|
|
handNumber: state.handNumber,
|
|
timestamp: Date.now(),
|
|
pot,
|
|
payouts,
|
|
ledger,
|
|
});
|
|
if (state.handHistory.length > this.MAX_HISTORY_ENTRIES) {
|
|
state.handHistory.length = this.MAX_HISTORY_ENTRIES;
|
|
}
|
|
state.handNumber += 1;
|
|
},
|
|
|
|
clearHistory(state) {
|
|
state.handHistory = [];
|
|
state.handNumber = 1;
|
|
},
|
|
|
|
// Running per-player totals for the whole session, so a dealer settling up at the
|
|
// end of the night reads one number per person instead of adding up every hand.
|
|
// Keyed by seat id, which survives renames and reseating; hands recorded before
|
|
// the ledger existed simply don't contribute to the totals.
|
|
sessionStandings(state) {
|
|
const totals = new Map();
|
|
|
|
(state.handHistory || []).forEach(entry => {
|
|
(entry.ledger || []).forEach(row => {
|
|
// History is newest-first, so the first name seen for an id is the
|
|
// most recent one the dealer typed.
|
|
const running = totals.get(row.id) || { id: row.id, name: row.name, in: 0, won: 0 };
|
|
running.in += row.in;
|
|
running.won += row.won;
|
|
totals.set(row.id, running);
|
|
});
|
|
});
|
|
|
|
// Cash bought in and chips currently in front don't come from the hand log at
|
|
// 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);
|
|
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
|
|
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 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
|
|
// position disagree with the hand log for as long as a hand is open —
|
|
// and blinds are posted the instant the previous one settles, so that
|
|
// window is almost always.
|
|
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
|
|
|
|
return {
|
|
...t,
|
|
in: Money.round(t.in),
|
|
won: Money.round(t.won),
|
|
net: Money.round(t.won - t.in),
|
|
boughtIn,
|
|
chipsNow,
|
|
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
|
};
|
|
})
|
|
.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) {
|
|
module.exports = HoldemState;
|
|
}
|