Home games don't run like a cardroom. People walk up mid-hand, storm off mid-hand, and pass the button round three times before anyone deals. Probing each of those found four problems, one of which loses chips. A player taken out mid-hand took their chips with them `computeContribution` opened with `if (!player.inHand) return 0`, so the moment a dealer switched a seat off, everything that seat had already put in vanished from the pot. Measured: four players in for $2 each, one taken out, pot drops from $8 to $6. Their $2 was still sitting on the seat record — it just stopped counting. A dealer following the tool would have paid out $6 of an $8 pot. Chips already pushed into the middle are dead money. They stay in the pot and go to whoever wins, whether or not the player is still in the hand, and it is not theirs to take back. The `inHand` check now guards only the table-wide base-pot default, which does have to stay tied to being dealt in or every empty chair would be treated as having anted. The pot wizard counts a departed seat as folded, so its chips are in the pot but it can never be picked as a winner. Passing the button before the cards come out Moving the button left the posted blinds where they were while everything positional started reading off the new spot, so the money and the blind positions silently disagreed. While nothing but the blinds are in, moving the button now refunds them and re-posts from where it lands — pass it round six times and the chip count is unchanged. Once anybody has acted the blinds are settled and it stops offering. Naming the blinds by hand Blinds were purely positional with no way to overrule them. The seat menu now offers "Make small blind" / "Make big blind" while only the blinds are in, and a badge with a ring marks a blind that was named rather than dealt. Naming one pins the other where it is. It's refused if it would put both blinds on one player or post to somebody who isn't dealt in, and it lasts one hand — carrying it forward would freeze the blinds on two people for the night. The first hand, and a table that empties out A fresh table puts the button on seat 1 whether or not anyone is sitting there, and a table can empty out around it. The button is moved onto a real player before a hand is dealt. Joining mid-hand A newcomer was added straight into the live seats and would be asked to act in a hand they hadn't been paying into. They now sit the hand out and are a full player from the next one. Nothing is covered on a phone any more Swept 13 viewport sizes with elementFromPoint, five points per control, on the table and every overlay. Before: the action bar covered the bottom seats on short screens (62px of felt at 360x640), the toast covered up to six seats at once — Undo button included, so a tap meant for a player hit "Undo" — and in landscape the table came out 765px tall in a 390px window, putting six of nine seats off-screen entirely. - the action bar's real height is published as `--actionBarHeight` and drives both the page's bottom padding and the toast, replacing a hardcoded 9rem that was wrong the moment the raise pad opened - landscape sizes the felt from the height that's left, not from width. The first attempt did nothing: `padding-bottom` is a percentage of the containing block's *width*, so narrowing the wrap left it full height, and a `max-width: 900px` rule later in the file was overriding it anyway. It's set outright now and lives at the end of the sheet. - on short screens the toast floats over the action bar's status readout rather than over the seats. Nowhere is clear of both; covering something you re-read beats covering something you tap. - headers go compact on short screens, which is where the vertical room came from 13/13 sizes clean, 360x640 through 844x390, portrait and landscape. Also fixes a marker that flickered: `classList.toggle(cls, undefined)` toggles rather than clearing, and `manual` is undefined for positional blinds, so the hand-picked ring appeared on alternate renders. 18 new tests (76 total, all passing). Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
384 lines
16 KiB
JavaScript
384 lines
16 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;
|
|
},
|
|
|
|
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]));
|
|
|
|
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);
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = HoldemState;
|
|
}
|