Opening the tool in a second tab -- tapping the bookmark again rather than hunting for
the tab you already had -- silently throws away whatever the first one is doing.
Each tab holds its own copy in memory and each save writes the whole table, so the
second one to act wins. Driven with two tabs on one profile:
tab A: deal, and Ann calls stored -> bettingOpen true, committed [0,1,2,2]
tab B: type a name in seat 5 stored -> bettingOpen false, committed [0,0,0,0]
The deal, the blinds and the call are gone, and neither tab said anything. That is the
same silent shape as the save and load failures either side of this change.
The browser already tells us: a storage event fires in every other tab when the key
changes, and never in the one that wrote. So a tab that sees its table move underneath
it now says so, once, with the way out attached:
toast: "This table is open in another tab. Whichever acts last wins." [Reload]
panel: "This table is open in another tab, and both are writing to it — whichever
acts last wins, so work done in the other one can vanish. Close one, then
reload this tab to pick up the table as it stands."
Watched rather than merged, deliberately. Replacing the table under a dealer's hands
in the middle of a hand would be its own hazard, and reloading is the only resolution
that does not throw somebody's work away -- so it is offered rather than taken.
6 tests (269 total): a write from elsewhere is noticed, another app's key and our own
recovery key are not our business, ten writes produce one warning, watching twice
listens once, no listener is not an error, and no window at all is not an error.
Driven with two live tabs: both quiet at first, A deals and B warns with the reload
offer, A stays quiet about its own writes, two more actions in A add no further toasts
in B, and reloading B picks up A's table -- bettingOpen true, committed [2,1,2,2] --
with the warning cleared. Audit and contrast clean, no console errors.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
598 lines
26 KiB
JavaScript
598 lines
26 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,
|
|
// Whether the betting has actually been opened for this hand.
|
|
//
|
|
// Dealing and betting used to be one button: "Next Hand" moved the button
|
|
// and posted the blinds in the same breath. That meant the moment between
|
|
// hands — the one where people are still sitting down, standing up and
|
|
// arguing about whose turn it is — was spent with money already committed,
|
|
// and the blind badges recomputing off `inHand` while the posted chips
|
|
// stayed frozen from when the hand began. Seat somebody after pressing it
|
|
// and the table would show a big blind who had posted nothing.
|
|
//
|
|
// So the dealer says when. False means the table is being set up: nothing
|
|
// is committed, the badges are a preview, and seating or moving the button
|
|
// costs nothing.
|
|
bettingOpen: false,
|
|
// 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,
|
|
};
|
|
},
|
|
|
|
// Where an unreadable save is kept instead of being written over.
|
|
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
|
|
|
|
// True once another tab has written to this table. Two tabs each hold their own
|
|
// copy in memory and each save writes the whole thing, so the second one to act
|
|
// silently throws the first one's work away — measured: tab A deals and takes a
|
|
// call, tab B renames a seat, and the deal, the blinds and the call are gone with
|
|
// nothing said in either tab.
|
|
//
|
|
// Watched rather than merged. Replacing the table under a dealer's hands mid-hand
|
|
// would be its own hazard; knowing there are two of them is what they need.
|
|
otherTabActive: false,
|
|
onOtherTab: null,
|
|
|
|
// The browser fires this in every *other* tab, never the one that wrote, so our
|
|
// own saves can't trip it.
|
|
watchOtherTabs() {
|
|
if (typeof window === "undefined" || this.tabWatcher) return;
|
|
this.tabWatcher = event => {
|
|
if (event.key !== this.STORAGE_KEY) return;
|
|
if (this.otherTabActive) return;
|
|
this.otherTabActive = true;
|
|
if (typeof this.onOtherTab === "function") this.onOtherTab();
|
|
};
|
|
window.addEventListener("storage", this.tabWatcher);
|
|
},
|
|
|
|
// True when there was a saved table and it couldn't be read. Same pair as
|
|
// saveFailed above, for the same reason: the UI has to be able to say so.
|
|
loadFailed: false,
|
|
onLoadFailed: null,
|
|
|
|
// A saved table that won't parse becomes an empty table, which is the only thing
|
|
// it can become — but it used to become one silently, and then the first edit
|
|
// saved over the top of it. A dealer mid-session saw an empty felt with no idea
|
|
// whether they'd mis-tapped Reset or the browser had lost the night, and whatever
|
|
// a person might have picked out of the raw JSON was gone a tap later.
|
|
//
|
|
// So the unreadable value is moved aside first. It is not offered as a restore —
|
|
// half a JSON document isn't a table, and pretending otherwise would be worse than
|
|
// an empty one — but it stops being destroyed, and somebody who wants the
|
|
// standings out of it still can.
|
|
failedLoad(raw) {
|
|
if (!this.loadFailed) {
|
|
this.loadFailed = true;
|
|
try {
|
|
if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
|
|
} catch {
|
|
// No room to keep it. Nothing more to try, and the warning below is
|
|
// the part that actually matters.
|
|
}
|
|
if (typeof this.onLoadFailed === "function") this.onLoadFailed();
|
|
}
|
|
return this.createDefault();
|
|
},
|
|
|
|
load() {
|
|
let raw = null;
|
|
try {
|
|
raw = localStorage.getItem(this.STORAGE_KEY);
|
|
if (!raw) return this.createDefault();
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
|
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];
|
|
});
|
|
|
|
// A table saved before dealing and betting were separate has no flag, and
|
|
// guessing false would show "deal the hand" over a hand already in
|
|
// progress. Anything already committed, or anyone already on the clock,
|
|
// means the betting was open when they closed the tab.
|
|
if (parsed.bettingOpen === undefined) {
|
|
parsed.bettingOpen =
|
|
parsed.actingSeatIndex !== null && parsed.actingSeatIndex !== undefined ||
|
|
parsed.seats.some(p =>
|
|
(Number(p.committed) || 0) > 0 || (Number(p.amountOverride) || 0) > 0);
|
|
}
|
|
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 {
|
|
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
|
return this.failedLoad(raw);
|
|
}
|
|
},
|
|
|
|
// True once a save has failed. The UI reads this to keep saying so, because a
|
|
// toast is a moment and this is a fact about the rest of the night.
|
|
saveFailed: false,
|
|
|
|
// Called the first time a save fails. Set by the UI.
|
|
onSaveFailed: null,
|
|
|
|
save(state) {
|
|
try {
|
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
|
return true;
|
|
} catch (error) {
|
|
// Storage full, or private browsing. The table still works for the rest of
|
|
// the session — it just won't survive a refresh, and nothing used to say
|
|
// so. That is the same shape of fault the bank check exists for: silent
|
|
// until it is too late to do anything about it. A dealer who finds out by
|
|
// refreshing has lost the night's standings, and the only warning they got
|
|
// was the table being empty.
|
|
//
|
|
// Announced once. A dealer tapping a bar thirty times a hand does not need
|
|
// thirty of these.
|
|
if (!this.saveFailed) {
|
|
this.saveFailed = true;
|
|
if (typeof this.onSaveFailed === "function") this.onSaveFailed(error);
|
|
}
|
|
return false;
|
|
}
|
|
},
|
|
|
|
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.
|
|
// Where the button will be when the hand is dealt — without moving it.
|
|
//
|
|
// Kept separate from ensureButtonOnPlayer because the deal preview has to be able
|
|
// to ask the question without answering it. It couldn't, and so the preview lied:
|
|
// with the button parked on an empty chair it promised one pair of blinds and
|
|
// dealing produced another, because beginHand relocates the button first and then
|
|
// works out who posts.
|
|
buttonSeatForDeal(state) {
|
|
const seated = state.seats[state.dealerSeatIndex];
|
|
if (seated && seated.inHand) return state.dealerSeatIndex;
|
|
for (let step = 0; step < state.seatCount; step++) {
|
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
|
if (state.seats[index].inHand) return index;
|
|
}
|
|
return state.dealerSeatIndex;
|
|
},
|
|
|
|
ensureButtonOnPlayer(state) {
|
|
state.dealerSeatIndex = this.buttonSeatForDeal(state);
|
|
},
|
|
|
|
// 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);
|
|
// Cards aren't out and nothing is posted until the dealer says so.
|
|
state.bettingOpen = false;
|
|
state.actingSeatIndex = null;
|
|
state.street = "preflop";
|
|
state.currentBet = 0;
|
|
state.lastRaiseSize = 0;
|
|
// 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);
|
|
},
|
|
|
|
// Two players change chairs.
|
|
//
|
|
// The whole seat moves — name, stack, cards, what they have in the pot — because
|
|
// that is what changing chairs is. What stays behind is everything that belongs to
|
|
// the *position*: the button, the blinds and the acting index all name a chair, and
|
|
// a player who moves into the big blind's chair is the big blind. That is the same
|
|
// answer a card room gives.
|
|
swapSeats(state, a, b) {
|
|
const seats = state.seats || [];
|
|
if (a === b) return false;
|
|
if (!seats[a] || !seats[b]) return false;
|
|
[seats[a], seats[b]] = [seats[b], seats[a]];
|
|
return true;
|
|
},
|
|
|
|
// 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;
|
|
}
|