Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
T
jheapsandClaude Sonnet 5 7bedee2934 Track the betting live so the dealer taps instead of types
Until now a dealer kept the money in their head and typed each player's total
into a form. This adds the running hand: blinds post themselves, every seat has
a stack, and one tap per decision moves the action on.

The bar sits at the bottom of the screen where a thumb already is, and shows
only what's true right now — Fold, then Check or Call with the amount owed, then
Bet or Raise. Calling more than a player has left says "Call all-in" rather than
quoting a number they can't cover. Raising offers min, half pot, pot and all-in
before it offers a keypad, because a dealer calling "raise to sixty" wants a
button. When everyone has answered, the bar becomes Deal Flop, then Turn, River,
and finally Showdown, which hands off to the pot wizard that already existed.
Winnings go straight back in front of the winner, so the next hand's stacks are
already right.

The money hooks into what was already there rather than running beside it: a
street's chips sweep into each seat's amountOverride, the same field the pot
badge, the pot wizard, the session standings and the CSV export all read. So
nothing downstream changed, and a dealer who prefers the old typed workflow
still has it.

Tables saved before this exist without any of these fields, so load() fills
them in rather than making someone reset a table mid-session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 19:01:08 -06:00

253 lines
9.3 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,
};
},
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,
};
},
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"]
.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;
});
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.
computeContribution(player, chipLegend, basePotAmount) {
if (!player.inHand) return 0;
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;
const base = Number(basePotAmount);
return Number.isNaN(base) ? 0 : Math.max(0, base);
},
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;
},
advanceButton(state) {
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
},
getBlindSeats(state) {
const sbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
const bbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 2);
return { dealerIndex: state.dealerSeatIndex, sbIndex, bbIndex };
},
startNextHand(state) {
this.advanceButton(state);
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
p.chipCounts = {};
p.amountOverride = null;
p.committed = 0;
p.hasActed = false;
});
},
// 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);
});
});
return Array.from(totals.values())
.map(t => ({
...t,
in: Math.round(t.in * 100) / 100,
won: Math.round(t.won * 100) / 100,
net: Math.round((t.won - t.in) * 100) / 100,
}))
.sort((a, b) => b.net - a.net);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HoldemState;
}