314 lines
12 KiB
JavaScript
314 lines
12 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,
|
|
};
|
|
},
|
|
|
|
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;
|
|
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.
|
|
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;
|
|
},
|
|
|
|
// 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;
|
|
},
|
|
|
|
// 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;
|
|
|
|
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);
|
|
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);
|
|
});
|
|
});
|
|
|
|
// 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]));
|
|
|
|
return Array.from(totals.values())
|
|
.map(t => {
|
|
const seat = seatById.get(t.id);
|
|
const boughtIn = seat ? Math.round((Number(seat.buyIns) || 0) * 100) / 100 : 0;
|
|
const chipsNow = seat ? Math.round((Number(seat.stack) || 0) * 100) / 100 : 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: Math.round(t.in * 100) / 100,
|
|
won: Math.round(t.won * 100) / 100,
|
|
net: Math.round((t.won - t.in) * 100) / 100,
|
|
boughtIn,
|
|
chipsNow,
|
|
cashPosition: Math.round((chipsNow + inFrontOfThem - boughtIn) * 100) / 100,
|
|
};
|
|
})
|
|
.sort((a, b) => b.net - a.net);
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = HoldemState;
|
|
}
|