Three failures that only ever happen at a table, none of which show up on a desk with a good connection and a plugged-in laptop. The offline promise depended on how the URL was spelled. Razor Pages routing is case-insensitive, so /Casino/TexasHoldem serves the identical page; a service worker's scope is matched as a plain string prefix, so a worker scoped to /casino/ does not control it. The registration succeeded, the worker activated, nothing was logged, and the cache stayed completely empty -- because the precache list was handed to navigator.serviceWorker.ready, which resolves with the registration controlling the page and so never resolves at all outside its scope. A dealer who opened that URL to load the tool for the night had loaded nothing. One spelling wins now, via a 301 scoped to /casino rather than applied site-wide: every file under wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing there cannot miss a real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the site, and /chessHub would break. The handshake is fixed rather than left to be saved by the redirect -- it takes the worker off the registration itself. The manifest icons were never cached, because the shell list is read off the DOM and the DOM never mentions them. They are read out of the manifest now, cache-first so an offline launch stops logging ERR_INTERNET_DISCONNECTED, and the link sweep picks up apple-touch-icon, which is the only one iOS reads. The screen slept mid-hand. A wake lock is held while betting is open and dropped the moment it is not: through a whole street of renders it takes exactly one, two syncs in a tick take exactly one, a hand that ends inside the request leaves nothing held, and the browser taking it back leaves us able to take it again. Announced once with a Turn off button rather than a permanent header indicator; a setting after that. And localStorage is evictable, which the Save & reset panel now says out loud. persist() is asked for on Post blinds & deal, because every browser wants that request to come from something the dealer did. 885 tests pass, 18 of them new. Five fault injections into the wake lock are each caught. One of the new tests was green for the wrong reason first: globalThis.navigator = fake does nothing, because Node has defined navigator as a getter since v21. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
1357 lines
67 KiB
JavaScript
1357 lines
67 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: [],
|
|
// Chips taken off the table on purpose, so the bank check can tell them
|
|
// apart from chips that have gone missing.
|
|
discarded: 0,
|
|
smallBlind: 1,
|
|
bigBlind: 2,
|
|
defaultStack: null,
|
|
street: "preflop",
|
|
// The community cards, in the order they hit the felt: three, then one,
|
|
// then one. Empty strings for the ones that haven't been turned yet, the
|
|
// same way a seat's two hole cards are stored.
|
|
board: ["", "", "", "", ""],
|
|
// Whether the dealer wants to be asked for them at all. Off is the tool as
|
|
// it has always been: no board on the felt, and the pot wizard asks who
|
|
// won. On, it reads the showdown and answers that question itself.
|
|
trackBoard: false,
|
|
// Whether to stop the screen sleeping while a hand is live. On, because a
|
|
// dealer who has to wake their phone between the flop and the turn is the
|
|
// single most common way this tool gets in the way of the game it is meant
|
|
// to be running. Only ever held while betting is open — see wakeLock.js.
|
|
keepScreenAwake: true,
|
|
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,
|
|
// Who actually posted, the last time cards went out — { sbIndex, bbIndex }.
|
|
// The next hand's rotation is worked out from this rather than from the
|
|
// seating as it stands, because by then the people it needs to know about
|
|
// are exactly the ones who have got up and gone.
|
|
postedBlinds: null,
|
|
// Who last bet or raised, on this street and anywhere in the hand. Kept
|
|
// apart because the showdown rule needs both: normally the aggressor of the
|
|
// final betting round shows first, but a round nobody could bet in — chips
|
|
// already all-in — falls back to the last one who could.
|
|
streetAggressorIndex: null,
|
|
handAggressorIndex: null,
|
|
// Player ids that were dealt in the last hand actually dealt. What makes a
|
|
// seat recognisable as having *just* joined — new to the table, or back
|
|
// from sitting out — which is the whole question behind owing a blind.
|
|
dealtLastHand: [],
|
|
// Ids the dealer has said will buy in past the blinds this hand, by posting
|
|
// the big blind rather than waiting for it. Cleared every hand: it's a
|
|
// decision about one deal, not a standing arrangement.
|
|
postingIn: [],
|
|
// How many straddles are on for the hand about to be dealt. A straddle is
|
|
// announced hand by hand, so this is cleared with every other one-deal
|
|
// decision rather than standing until somebody turns it off.
|
|
straddleCount: 0,
|
|
// What the house takes, if it takes anything. Percent zero is a game that
|
|
// doesn't rake, which is nearly all of them and the default.
|
|
rake: { percent: 0, cap: null, noFlopNoDrop: true },
|
|
// What it has taken so far tonight. A session total, not a per-hand one:
|
|
// it is the number the box is checked against at the end.
|
|
rakeTaken: 0,
|
|
// People who have taken their chips and gone. Kept for the night rather
|
|
// than forgotten with the seat: the box took their cash and paid it back
|
|
// out, and both halves have to stay on the record or the bank check reads
|
|
// as a shortfall for the rest of the evening.
|
|
cashedOut: [],
|
|
// People standing behind the chairs. A dealer at a full table keeps this
|
|
// list on a napkin; keeping it here means the tool can offer the next seat
|
|
// that opens to whoever has been waiting longest.
|
|
waitingList: [],
|
|
// Which seats actually put a straddle out when the hand was dealt, so the
|
|
// felt can badge them. Read off the deal rather than worked out again
|
|
// later, the same way the posted blinds are.
|
|
postedStraddles: [],
|
|
// Set when somebody leaves out of the blinds and the rotation has to hold
|
|
// to the seats to keep the big blind moving one player at a time. The
|
|
// button then sits on the chair they vacated, and no small blind is posted.
|
|
deadButton: false,
|
|
deadSmallBlind: false,
|
|
};
|
|
},
|
|
|
|
// 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();
|
|
},
|
|
|
|
// Everything a table has to be put through before it can be trusted, whichever
|
|
// direction it arrived from.
|
|
//
|
|
// Pulled out of load() so a table read from a file goes through exactly the same
|
|
// checks as one read from storage. A second, looser path for imports is how a
|
|
// format grows two meanings of the same field.
|
|
//
|
|
// Returns null for anything that isn't a table at all; every other repair is
|
|
// silent and forward, the way a save from an older version is treated.
|
|
migrate(parsed) {
|
|
if (!parsed || !Array.isArray(parsed.seats)) return null;
|
|
{
|
|
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
|
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
|
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
|
|
|
// 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", "postedBlinds",
|
|
"streetAggressorIndex", "handAggressorIndex"]
|
|
.forEach(key => {
|
|
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
|
});
|
|
|
|
// A table saved before the dead button existed has neither flag. False is
|
|
// the right guess for both: it means the rotation carries on the ordinary
|
|
// way, which is what that table was already doing.
|
|
// A table saved before the board existed has no board and was not tracking
|
|
// one. Off is also the right guess for a dealer who never asked for it.
|
|
parsed.trackBoard = parsed.trackBoard === true;
|
|
parsed.board = this.normaliseBoard(parsed.board);
|
|
|
|
// Note the direction: `!== false` rather than `=== true`. A table saved
|
|
// before this setting existed has no opinion about it, and the useful guess
|
|
// for a dealer who has never been asked is the helpful one. Only somebody
|
|
// who has actually turned it off gets it off.
|
|
parsed.keepScreenAwake = parsed.keepScreenAwake !== false;
|
|
|
|
parsed.deadButton = parsed.deadButton === true;
|
|
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
|
|
// An old save has no record of who was dealt last hand. Empty is the
|
|
// honest answer — it reads as "this table hasn't played yet", so the first
|
|
// hand after reopening treats nobody as having just walked up, rather than
|
|
// asking the whole table to post.
|
|
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
|
|
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
|
|
// None on is the right reading of a save from before straddles existed:
|
|
// whatever is in front of those players is already in their committed
|
|
// chips, and nothing is owed on top of it.
|
|
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
|
|
// A table saved before the house took anything takes nothing, which is
|
|
// also the right answer for one that never will.
|
|
const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
|
|
parsed.rake = {
|
|
percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
|
|
cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
|
|
noFlopNoDrop: rake.noFlopNoDrop !== false,
|
|
};
|
|
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
|
|
parsed.waitingList = Array.isArray(parsed.waitingList)
|
|
? parsed.waitingList
|
|
.map(row => (typeof row === "string" ? { id: null, name: row } : row))
|
|
.filter(row => row && String(row.name || "").trim())
|
|
.map(row => ({
|
|
id: row.id || this.makeId("waiting"),
|
|
name: String(row.name).trim().slice(0, 24),
|
|
addedAt: Number(row.addedAt) || 0,
|
|
}))
|
|
: [];
|
|
parsed.cashedOut = Array.isArray(parsed.cashedOut)
|
|
? parsed.cashedOut
|
|
.filter(row => row && row.id)
|
|
.map(row => ({
|
|
id: row.id,
|
|
name: String(row.name || "").slice(0, 40),
|
|
buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)),
|
|
chips: Math.max(0, Money.round(Number(row.chips) || 0)),
|
|
at: Number(row.at) || 0,
|
|
}))
|
|
: [];
|
|
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
|
|
|
|
// 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;
|
|
}
|
|
},
|
|
|
|
load() {
|
|
let raw = null;
|
|
try {
|
|
raw = localStorage.getItem(this.STORAGE_KEY);
|
|
if (!raw) return this.createDefault();
|
|
const migrated = this.migrate(JSON.parse(raw));
|
|
if (!migrated) return this.failedLoad(raw);
|
|
return migrated;
|
|
} catch {
|
|
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
|
return this.failedLoad(raw);
|
|
}
|
|
},
|
|
|
|
// ---- Taking the table off this device ------------------------------------
|
|
//
|
|
// A night lives in one browser's localStorage and nowhere else. A dealer whose
|
|
// phone dies, or who wants to carry on from the tablet after the break, loses the
|
|
// stacks, the buy-ins, where the button is and the whole hand log — and the CSV
|
|
// export can't bring any of that back, because it records what happened rather
|
|
// than where the table stands.
|
|
//
|
|
// So the table can be written out as a file and read back in. Same shape that goes
|
|
// into storage, wrapped in enough to recognise it later.
|
|
// ---- Asking the browser to actually keep it -------------------------------
|
|
//
|
|
// "The night lives in this browser and nowhere else" is truer than it sounds.
|
|
// localStorage is *evictable*: when a device runs short, the browser clears
|
|
// site data to make room, and it starts with the sites it thinks nobody cares
|
|
// about. Safari goes further and deletes script-created storage after seven days
|
|
// without a visit — so a table saved at last month's game can simply not be there
|
|
// at this one, with nothing having gone wrong and nobody having deleted anything.
|
|
//
|
|
// `persist()` is the way to say this data is not that. Granted, the storage can
|
|
// only be cleared by the person whose browser it is. Chromium and Safari decide
|
|
// silently on how much the site has been used and whether it's installed; Firefox
|
|
// asks. All three want the request to come from something the dealer did, which is
|
|
// why this fires on the first hand dealt rather than on page load.
|
|
supportsDurableStorage() {
|
|
return typeof navigator !== "undefined"
|
|
&& !!navigator.storage
|
|
&& typeof navigator.storage.persist === "function";
|
|
},
|
|
|
|
async isDurable() {
|
|
if (!this.supportsDurableStorage()) return false;
|
|
try {
|
|
return await navigator.storage.persisted();
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
// Returns whether the browser said yes. A no is not a failure worth reporting at a
|
|
// table — the tool behaves identically either way, and the only honest difference
|
|
// is how confident the Save & reset panel is allowed to sound.
|
|
async requestDurable() {
|
|
if (!this.supportsDurableStorage()) return false;
|
|
try {
|
|
if (await navigator.storage.persisted()) return true;
|
|
return await navigator.storage.persist();
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
BACKUP_KIND: "casino.holdem.table",
|
|
BACKUP_VERSION: 1,
|
|
|
|
toBackup(state) {
|
|
return JSON.stringify({
|
|
kind: this.BACKUP_KIND,
|
|
version: this.BACKUP_VERSION,
|
|
savedAt: new Date().toISOString(),
|
|
table: state,
|
|
}, null, 2);
|
|
},
|
|
|
|
// Reads a file back. Returns { table } on success, or { error } with something a
|
|
// dealer can act on — never a half-loaded table, because the failure mode there is
|
|
// a night that looks restored and isn't.
|
|
fromBackup(text) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
return { error: "That file isn't a saved table — it couldn't be read as JSON." };
|
|
}
|
|
if (!parsed || typeof parsed !== "object") {
|
|
return { error: "That file isn't a saved table." };
|
|
}
|
|
// A bare table, from someone who copied the storage value out by hand, is
|
|
// still a table. The envelope is a convenience, not a password.
|
|
const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
|
|
if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
|
|
return { error: "That file is a save from something else, not a Hold'em table." };
|
|
}
|
|
const migrated = this.migrate(table);
|
|
if (!migrated) {
|
|
return { error: "That file doesn't have a table in it — no seats were found." };
|
|
}
|
|
if (parsed.version > this.BACKUP_VERSION) {
|
|
// Newer than this build understands. Loading it anyway risks reading a
|
|
// field that has changed meaning, and the dealer would find out from the
|
|
// money, so say so instead.
|
|
return { error: "That table was saved by a newer version of this tool. Update, then load it." };
|
|
}
|
|
return { table: migrated, savedAt: parsed.savedAt || null };
|
|
},
|
|
|
|
// What the file is called. Dated, because a dealer who saves twice in a night
|
|
// should not have to guess which is which.
|
|
backupFileName(now = new Date()) {
|
|
const pad = n => String(n).padStart(2, "0");
|
|
return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
|
`-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
|
|
},
|
|
|
|
// 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.
|
|
//
|
|
// The fallback, now. nextButtonRotation is what a hand that was actually dealt
|
|
// rotates by; this is for the cases it has nothing to work from — a fresh table,
|
|
// a hand never dealt, a dealer pressing Next Hand twice to pass the button on.
|
|
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;
|
|
// A button left on an empty chair on purpose is the dead button, and moving it
|
|
// to a player is precisely what it's there to stop. Only a button that has been
|
|
// stranded by accident gets rescued.
|
|
if (state.deadButton && this.deadPositionsApply(state)) 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;
|
|
},
|
|
|
|
// A dead button and a dead small blind are three-handed-and-up ideas. Down to two
|
|
// players the button *is* the small blind and the blinds simply alternate, so
|
|
// whatever the last rotation left behind stops applying.
|
|
deadPositionsApply(state) {
|
|
return state.seats.filter(p => p.inHand).length >= 3;
|
|
},
|
|
|
|
// 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 };
|
|
}
|
|
|
|
// The small blind is dead: whoever would have moved into it has left the table,
|
|
// and the seat stays empty for a hand rather than dragging the big blind
|
|
// forward an extra place. One player gets a free hand out of position for it;
|
|
// that's the price of nobody being charged two big blinds in an orbit.
|
|
if (state.deadSmallBlind && this.deadPositionsApply(state)) {
|
|
return { dealerIndex, sbIndex: null, bbIndex: live[0], headsUp: false, deadSmallBlind: true };
|
|
}
|
|
|
|
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
|
|
},
|
|
|
|
// Seats about to be dealt a hand they haven't paid for.
|
|
//
|
|
// Somebody who walks up and sits down between the button and the big blind has had
|
|
// the blinds go past them. Dealing them in there is a free hand in late position —
|
|
// which is exactly what posting is for, and it comes out of everybody else's night
|
|
// a couple of dollars at a time. At a real table they either post the big blind to
|
|
// come straight in, or they wait for it to reach them.
|
|
//
|
|
// The seat that *is* the big blind this hand isn't owed anything: they're paying.
|
|
// Neither is anyone at a table that hasn't dealt a hand yet, where everybody is new
|
|
// and nobody has been skipped.
|
|
blindDebtors(state, bbIndex) {
|
|
const played = Array.isArray(state.dealtLastHand) && state.dealtLastHand.length > 0;
|
|
if (!played) return [];
|
|
return state.seats
|
|
.map((player, index) => ({ player, index }))
|
|
.filter(({ player, index }) =>
|
|
player.inHand &&
|
|
index !== bbIndex &&
|
|
!state.dealtLastHand.includes(player.id))
|
|
.map(({ index }) => index);
|
|
},
|
|
|
|
// Where the button goes for the next hand, and whether anything is left dead.
|
|
//
|
|
// Ordinarily it follows the small blind round — the next player along, which is
|
|
// what it has always done. The case that needs care is somebody getting up out of
|
|
// the blinds. Passing the button to the next *player* then pulls the whole blind
|
|
// order forward a place: the player after the empty chair is never asked for a big
|
|
// blind at all, and the one after them is asked an orbit early. Holding the
|
|
// rotation to the seats instead is the dead button, and getting it wrong costs a
|
|
// real player a real big blind.
|
|
//
|
|
// Worked out from who actually posted last hand, not from the seating as it stands,
|
|
// because by now the people it needs to know about are the ones who have gone.
|
|
nextButtonRotation(state) {
|
|
const posted = state.postedBlinds;
|
|
const inRange = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i];
|
|
const here = i => inRange(i) && state.seats[i].inHand;
|
|
|
|
// Nothing to hold to: a fresh table, a hand that was never dealt, or one where
|
|
// the blinds were named by hand. And heads-up has its own rule — with two
|
|
// players the button is the small blind, so there is no position to leave dead.
|
|
if (!posted || !inRange(posted.sbIndex) || !inRange(posted.bbIndex) || !this.deadPositionsApply(state)) {
|
|
return { buttonIndex: null, deadButton: false, deadSmallBlind: false };
|
|
}
|
|
|
|
// Both still sitting there is the ordinary case, and this comes out as the next
|
|
// player along — the same answer as passing the button, arrived at from the
|
|
// blinds so that the unusual case doesn't need a separate path.
|
|
return {
|
|
buttonIndex: posted.sbIndex,
|
|
deadButton: !here(posted.sbIndex),
|
|
deadSmallBlind: !here(posted.bbIndex),
|
|
};
|
|
},
|
|
|
|
startNextHand(state) {
|
|
const rotation = this.nextButtonRotation(state);
|
|
if (rotation.buttonIndex === null) {
|
|
this.advanceButton(state);
|
|
state.deadButton = false;
|
|
state.deadSmallBlind = false;
|
|
} else {
|
|
state.dealerSeatIndex = rotation.buttonIndex;
|
|
state.deadButton = rotation.deadButton;
|
|
state.deadSmallBlind = rotation.deadSmallBlind;
|
|
}
|
|
// Spent. Pressing Next Hand again without dealing is a dealer passing the
|
|
// button along, not a second rotation off the same hand.
|
|
state.postedBlinds = null;
|
|
// 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;
|
|
// Posting to come in is a decision about one deal. Carrying it forward would
|
|
// charge somebody an extra big blind every hand for the rest of the night.
|
|
state.postingIn = [];
|
|
// So is a straddle. It is announced hand by hand at a table, and a straddle
|
|
// that stayed on would take chips off somebody who never said the word.
|
|
state.straddleCount = 0;
|
|
state.postedStraddles = [];
|
|
this.clearBoard(state);
|
|
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);
|
|
},
|
|
|
|
// Exactly five slots of either a two-character card or an empty string, whatever
|
|
// arrived. A board read off a file is the one that has been somewhere this tool
|
|
// cannot see.
|
|
normaliseBoard(board) {
|
|
const source = Array.isArray(board) ? board : [];
|
|
return Array.from({ length: 5 }, (_, i) => {
|
|
const card = typeof source[i] === "string" ? source[i].trim().toUpperCase() : "";
|
|
return /^[23456789TJQKA][SHDC]$/.test(card) ? card : "";
|
|
});
|
|
},
|
|
|
|
clearBoard(state) {
|
|
state.board = ["", "", "", "", ""];
|
|
},
|
|
|
|
// 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, rake = 0) {
|
|
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
|
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
|
|
|
// The pot as it was, house cut included — the number the table saw in the
|
|
// middle. What each player took is separately recorded and is net of it, so
|
|
// the two together say where every chip went.
|
|
const taken = Money.round(Math.max(0, Number(rake) || 0));
|
|
const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken);
|
|
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,
|
|
rake: taken,
|
|
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]));
|
|
// And for anyone who has gone home, off the record their cash-out left behind.
|
|
// Without this they read as a player who bought in for nothing and is holding
|
|
// nothing, in the very list the dealer settles the night from.
|
|
const goneById = new Map((state.cashedOut || []).map(row => [row.id, row]));
|
|
|
|
goneById.forEach((row, id) => {
|
|
if (totals.has(id)) return;
|
|
totals.set(id, { id, name: row.name, in: 0, won: 0 });
|
|
});
|
|
|
|
// 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 ||
|
|
this.chipsHeldBy(state, player) > 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 gone = goneById.get(t.id);
|
|
const boughtIn = seat
|
|
? Money.round(Number(seat.buyIns) || 0)
|
|
: (gone ? gone.buyIns : 0);
|
|
// What a departed player is "holding" is what the box handed them on
|
|
// the way out. It is no longer on the table, so it is marked as paid
|
|
// rather than counted again at the end of the night.
|
|
const chipsNow = seat
|
|
? this.chipsHeldBy(state, seat)
|
|
: (gone ? gone.chips : 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;
|
|
|
|
// Whatever the dealer wrote against this seat — "IOU: $20 to Ann",
|
|
// "Venmo: @ann". It's carried here so the export can hand it over with
|
|
// the money it belongs to; the standings panel itself doesn't show it.
|
|
const notes = (seat && Array.isArray(seat.customFields) ? seat.customFields : [])
|
|
.filter(field => (field.label || "").trim() || (field.value || "").trim())
|
|
.map(field => `${(field.label || "").trim()}: ${(field.value || "").trim()}`.replace(/^: | :$/, ""))
|
|
.join("; ");
|
|
|
|
return {
|
|
...t,
|
|
in: Money.round(t.in),
|
|
won: Money.round(t.won),
|
|
net: Money.round(t.won - t.in),
|
|
boughtIn,
|
|
chipsNow,
|
|
// Everything still theirs: the stack in front of them plus whatever
|
|
// is out in the middle for the hand in progress. This is the number
|
|
// a banker counts out when somebody leaves, and it was the one
|
|
// figure the standings never showed — the column called "Cash out"
|
|
// held the difference against their buy-in, so a player up $140 on
|
|
// a $340 stack read as "$140" at the exact moment cash changed
|
|
// hands.
|
|
chipsInFront: Money.round(chipsNow + inFrontOfThem),
|
|
// Set once they have taken their chips and left, so the panel and
|
|
// the export can say so rather than showing a player still sitting
|
|
// there with a stack.
|
|
cashedOut: !!gone && !seat,
|
|
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
|
notes,
|
|
};
|
|
})
|
|
.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.
|
|
// ---- The people standing behind the chairs ----
|
|
//
|
|
// A full home game always has a couple of them, and the list lives on a napkin or
|
|
// in the dealer's head — which is where the argument comes from an hour later about
|
|
// who said they were next. Kept in order of arrival, because that is the only
|
|
// ordering anybody has ever agreed to.
|
|
|
|
addToWaitingList(state, name) {
|
|
const trimmed = String(name || "").trim().slice(0, 24);
|
|
if (!trimmed) return null;
|
|
if (!Array.isArray(state.waitingList)) state.waitingList = [];
|
|
const entry = { id: this.makeId("waiting"), name: trimmed, addedAt: Date.now() };
|
|
state.waitingList.push(entry);
|
|
return entry;
|
|
},
|
|
|
|
removeFromWaitingList(state, id) {
|
|
if (!Array.isArray(state.waitingList)) return null;
|
|
const index = state.waitingList.findIndex(row => row.id === id);
|
|
if (index < 0) return null;
|
|
return state.waitingList.splice(index, 1)[0];
|
|
},
|
|
|
|
// Chairs with nobody in them, in seat order. What "a seat has opened" means, and
|
|
// the same question the deal prompt answers when it counts the players.
|
|
openSeats(state) {
|
|
return state.seats
|
|
.map((player, index) => ({ player, index }))
|
|
.filter(({ player }) => !this.seatInPlay(player))
|
|
.map(({ index }) => index);
|
|
},
|
|
|
|
// Sit the next person down. Their name goes on the chair and nothing else does:
|
|
// they haven't bought in yet, and guessing a stack for them would put chips on the
|
|
// table that nobody has paid for — which is the one thing the bank check exists to
|
|
// catch.
|
|
seatFromWaitingList(state, id, index) {
|
|
const seat = state.seats[index];
|
|
if (!seat) return { ok: false, reason: "There's no such seat." };
|
|
if (this.seatInPlay(seat)) {
|
|
return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
|
|
}
|
|
const entry = this.removeFromWaitingList(state, id);
|
|
if (!entry) return { ok: false, reason: "They aren't on the list any more." };
|
|
|
|
seat.name = entry.name;
|
|
// Not dealt in and holding nothing: the next thing that happens is the dealer
|
|
// taking their money, and Add chips is what says so.
|
|
seat.inHand = false;
|
|
return { ok: true, name: entry.name, index };
|
|
},
|
|
|
|
// ---- Somebody leaving with their chips ----
|
|
//
|
|
// The most ordinary thing that happens at a home game after midnight, and the tool
|
|
// had nothing for it. A dealer's only way to free the chair was to wipe the seat by
|
|
// hand, and then the bank check — which adds up cash taken against chips on the
|
|
// table — declared the departing player's whole stack missing: "someone has paid
|
|
// for chips they haven't been given", for the rest of the night. Wiping their
|
|
// buy-in as well silenced it, at the price of losing their night from the standings
|
|
// and the file the dealer hands over.
|
|
//
|
|
// Cashing out is two facts, and both have to survive the empty chair: the box took
|
|
// their cash, and the box paid their chips back out.
|
|
|
|
// Can this seat be cashed out right now?
|
|
canCashOut(state, index) {
|
|
const player = state.seats[index];
|
|
if (!player) return { ok: false, reason: "There's nobody in that seat." };
|
|
const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0));
|
|
if (inPot > 0) {
|
|
return {
|
|
ok: false,
|
|
// Paying someone out with money still in the middle would either take
|
|
// chips out of a pot other people can win, or hand them chips twice.
|
|
reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`,
|
|
};
|
|
}
|
|
return { ok: true };
|
|
},
|
|
|
|
// What the dealer counts out, and what the seat's night comes to.
|
|
cashOutPreview(state, index) {
|
|
const player = state.seats[index];
|
|
if (!player) return null;
|
|
// Betting owns the answer to "what is this seat holding" — including the rule
|
|
// that a seat which has never been given a stack is holding the table's
|
|
// starting one. Asking it rather than working it out again here: the first
|
|
// draft of this did, read `Number(null)` as a perfectly good zero, and offered
|
|
// to pay a player nothing.
|
|
const chips = Money.round(Math.max(0, Betting.stackOf(state, player)));
|
|
const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0));
|
|
return {
|
|
index,
|
|
name: this.displayName(player, index),
|
|
chips,
|
|
boughtIn,
|
|
// Their result for the night, in the direction a banker says it: positive
|
|
// means the box is paying out more than it took from them.
|
|
net: Money.round(chips - boughtIn),
|
|
breakdown: Money.formatChipBreakdown(chips, state.chipLegend),
|
|
};
|
|
},
|
|
|
|
// Hand them their chips and free the chair, keeping both halves on the record.
|
|
cashOut(state, index) {
|
|
const allowed = this.canCashOut(state, index);
|
|
if (!allowed.ok) return allowed;
|
|
|
|
const preview = this.cashOutPreview(state, index);
|
|
const leavingId = state.seats[index].id;
|
|
if (!Array.isArray(state.cashedOut)) state.cashedOut = [];
|
|
state.cashedOut.push({
|
|
id: leavingId,
|
|
name: preview.name,
|
|
buyIns: preview.boughtIn,
|
|
chips: preview.chips,
|
|
at: Date.now(),
|
|
});
|
|
|
|
// The chair opens for the next player. A fresh id, because the person who had
|
|
// it has gone and their night is now filed under the old one — reusing it would
|
|
// merge two people's results the moment somebody new sits down.
|
|
const seat = this.makePlayer();
|
|
state.seats[index] = seat;
|
|
// Anyone owed a hand by this seat is no longer owed one.
|
|
state.postingIn = (state.postingIn || []).filter(id => id !== leavingId);
|
|
return { ok: true, ...preview };
|
|
},
|
|
|
|
// ---- The house's cut ----
|
|
//
|
|
// A game that takes a rake or a time charge has chips leaving the table every hand,
|
|
// and until now the bank check called them missing: it added up the buy-ins,
|
|
// added up the chips on the felt, and told the dealer somebody had been paid for
|
|
// chips they never got. The house's cut is the one thing on this table that is
|
|
// supposed to go and not come back, so it is counted rather than mourned.
|
|
//
|
|
// Percentage with a cap, which is what nearly every room and home game runs. Zero
|
|
// percent is a game that doesn't rake, which is the default and the common case.
|
|
|
|
// What the house takes from a pot of this size.
|
|
//
|
|
// "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
|
|
// flop pays nothing, because the players never got a hand out of it. It is on by
|
|
// default and can be turned off for a house that rakes every pot.
|
|
rakeOn(state, pot, sawFlop = true) {
|
|
const rake = state.rake || {};
|
|
const percent = Number(rake.percent) || 0;
|
|
const amount = Money.round(Number(pot) || 0);
|
|
if (percent <= 0 || amount <= 0) return 0;
|
|
if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
|
|
|
|
const uncapped = (amount * percent) / 100;
|
|
const cap = Number(rake.cap);
|
|
const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
|
|
// Never more than the pot, however the numbers were typed in.
|
|
return Money.downToChip(Math.min(capped, amount), state.chipLegend);
|
|
},
|
|
|
|
// The same payouts with the house's cut taken out of them.
|
|
//
|
|
// Off the top of the whole pot, then shared out in proportion — which for the hand
|
|
// a dealer actually deals, with one winner, is just "winner gets the pot less the
|
|
// rake". Split pots take it proportionally, and whatever the rounding leaves over
|
|
// goes back to the winners in turn rather than to the house: the house asked for a
|
|
// number, and it gets that number, not that number plus dust.
|
|
applyRake(payouts, rake) {
|
|
const total = payouts.reduce((sum, p) => sum + p.amount, 0);
|
|
const taken = Money.round(Math.min(Math.max(0, rake), total));
|
|
if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
|
|
|
|
const kept = Money.toCents(total) - Money.toCents(taken);
|
|
const shares = payouts.map(p => {
|
|
const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
|
|
return { payout: p, cents: Math.floor(exact) };
|
|
});
|
|
|
|
// Floor everybody, then hand the leftover cents out one at a time, biggest
|
|
// share first — the same shape as the odd-chip rule the split calculator uses.
|
|
let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
|
|
[...shares]
|
|
.sort((a, b) => b.payout.amount - a.payout.amount)
|
|
.forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
|
|
|
|
return {
|
|
payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
|
|
rake: taken,
|
|
};
|
|
},
|
|
|
|
// Is anybody actually in this chair? A seat with a name on it, chips in front of
|
|
// it, cash against it or a hand dealt to it. Empty chairs are not players, which
|
|
// matters the moment the table's starting stack starts standing in for a seat that
|
|
// has never been given one — every empty chair would otherwise look like somebody
|
|
// holding two hundred dollars.
|
|
seatInPlay(player) {
|
|
if (!player) return false;
|
|
return !!(player.name || "").trim() ||
|
|
(Number(player.buyIns) || 0) > 0 ||
|
|
(Number(player.stack) || 0) > 0 ||
|
|
(Number(player.committed) || 0) > 0 ||
|
|
(Number(player.amountOverride) || 0) > 0 ||
|
|
player.inHand === true;
|
|
},
|
|
|
|
// What a seat is holding, the way the felt and the betting see it — which includes
|
|
// the table's starting stack standing in for a seat that has never been given one.
|
|
//
|
|
// This is the number the money side of the tool was missing. "Starting stack" is
|
|
// offered in the setup panel as the ordinary way to open a table, and everything a
|
|
// dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
|
|
// check and the standings read `seat.stack` directly instead, which is null for
|
|
// exactly those seats: measured on a three-handed table with a $200 starting stack
|
|
// and nobody's own stack set, the felt played for $600 while the bank check said
|
|
// "no chips in play, balanced" and the standings listed nobody at all.
|
|
chipsHeldBy(state, player) {
|
|
if (!this.seatInPlay(player)) return 0;
|
|
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
|
},
|
|
|
|
// What a chip colour is holding up.
|
|
//
|
|
// Removing one is not a change to a legend. computeContribution() prices a seat by
|
|
// running the legend over that seat's chip counts, so a colour that leaves the
|
|
// legend takes every chip counted in it off the table. Measured on a three-handed
|
|
// table counting real chips: deleting the $500 row took $22,500 of $26,874 with it,
|
|
// on one click of a 24px button, with nothing said and nothing shown.
|
|
//
|
|
// It does not come back, either. A colour added again is a new colour with a new
|
|
// id, while the counts stay keyed to the one that left — so "put it back" restores
|
|
// the row and none of the money.
|
|
//
|
|
// Hence this: the cost is answerable before the click rather than discovered at the
|
|
// end of the night, when the box is short and nobody knows why.
|
|
// The number here is the *difference*, asked by pricing every seat twice — once with
|
|
// the colour and once without — rather than by multiplying the chips by their value.
|
|
//
|
|
// Those two are not the same, and the multiplication is the wrong one, because
|
|
// computeContribution() does not always reach the chips. A seat with a typed amount
|
|
// is priced from that and its chip counts are ignored, so a colour it holds is worth
|
|
// nothing to it; and a seat whose chips all leave falls through to the table's base
|
|
// pot — the standing ante — rather than to zero, so it loses less than its chips
|
|
// were worth.
|
|
// Both would put a number in the confirmation that the table then failed to match,
|
|
// which is worse than no number at all — this is a warning whose whole job is to be
|
|
// the truth.
|
|
chipColorUsage(state, chipId) {
|
|
const chip = (state.chipLegend || []).find(entry => entry.id === chipId);
|
|
if (!chip) return { chip: null, total: 0, chips: 0, seats: [] };
|
|
|
|
const legend = state.chipLegend || [];
|
|
const without = legend.filter(entry => entry.id !== chipId);
|
|
|
|
const seats = state.seats
|
|
.map((player, index) => {
|
|
const before = this.computeContribution(player, legend, state.basePotAmount);
|
|
const after = this.computeContribution(player, without, state.basePotAmount);
|
|
return {
|
|
index,
|
|
name: this.displayName(player, index),
|
|
count: Number((player.chipCounts || {})[chipId]) || 0,
|
|
amount: Money.round(before - after),
|
|
};
|
|
})
|
|
// Signed, and not filtered to losses, because removing a colour can put the
|
|
// table *up*. A seat whose chips are worth less than the table's standing
|
|
// ante falls through to the ante once they are gone: found by the randomised
|
|
// check at 14 chips of a $1 colour against a $200 base pot, where deleting it
|
|
// moved the table by +186. Rare, but a warning that said "nothing changes"
|
|
// there would be as wrong as one that named the wrong number.
|
|
.filter(row => row.amount !== 0);
|
|
|
|
return {
|
|
chip,
|
|
seats,
|
|
chips: seats.reduce((sum, row) => sum + row.count, 0),
|
|
total: Money.round(seats.reduce((sum, row) => sum + row.amount, 0)),
|
|
};
|
|
},
|
|
|
|
// Take a colour out, and take its counts with it.
|
|
//
|
|
// Leaving them behind is what the old code did — the seats kept counts against an
|
|
// id no legend entry answers to any more, so the file carried money that nothing
|
|
// could price and nothing could show. Clearing them makes the saved table say what
|
|
// the screen says. The usage is measured before anything moves, so a caller can
|
|
// report what it cost.
|
|
removeChipColor(state, chipId) {
|
|
const usage = this.chipColorUsage(state, chipId);
|
|
if (!usage.chip) return usage;
|
|
|
|
state.chipLegend = (state.chipLegend || []).filter(entry => entry.id !== chipId);
|
|
state.seats.forEach(player => {
|
|
if (player.chipCounts && chipId in player.chipCounts) delete player.chipCounts[chipId];
|
|
});
|
|
return usage;
|
|
},
|
|
|
|
// Seats playing with chips the box has no cash against.
|
|
//
|
|
// Two kinds, and they are not the same problem. A seat holding the table's starting
|
|
// stack has never been given chips of its own — the dealer set "everyone starts
|
|
// with 200" and sat people down, which is the ordinary way to open a table and the
|
|
// reason this whole gap existed. A seat with its own stack and no buy-in is a
|
|
// rebuy somebody forgot to write down, and only the dealer knows what it was.
|
|
seatsOwingCash(state) {
|
|
return state.seats
|
|
.map((player, index) => ({ player, index }))
|
|
.filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
|
|
!((Number(player.buyIns) || 0) > 0))
|
|
.map(({ player, index }) => ({
|
|
index,
|
|
name: this.displayName(player, index),
|
|
chips: this.chipsHeldBy(state, player),
|
|
// True when they are holding the table's starting stack rather than one
|
|
// the dealer typed for them.
|
|
fromStartingStack: player.stack === null || player.stack === undefined ||
|
|
String(player.stack).trim() === "",
|
|
}));
|
|
},
|
|
|
|
// Write down the cash for the seats that are holding the table's starting stack.
|
|
// Only those: what somebody else's unrecorded rebuy was is a question only the
|
|
// dealer can answer, and guessing it would be the tool inventing money.
|
|
recordStartingBuyIns(state) {
|
|
const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
|
owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
|
|
return {
|
|
seats: owed.length,
|
|
total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
|
|
};
|
|
},
|
|
|
|
bankReconciliation(state) {
|
|
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
|
|
// Cash the box took from people who have since left counts as taken: it is in
|
|
// the box, and forgetting it with the seat is what used to make a cash-out read
|
|
// as a shortfall all night.
|
|
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) +
|
|
gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0);
|
|
const chipsInPlay = state.seats.reduce(
|
|
(sum, p) =>
|
|
sum +
|
|
this.chipsHeldBy(state, p) +
|
|
Math.max(0, Number(p.committed) || 0) +
|
|
Math.max(0, Number(p.amountOverride) || 0),
|
|
0
|
|
);
|
|
|
|
// Chips the dealer deliberately took off the table — an abandoned pot they
|
|
// were asked about and confirmed. They are gone, so they can't be counted as
|
|
// in play, but they aren't missing either: the box knows exactly where they
|
|
// went. Without this the check blamed the tool's own confirmed action on
|
|
// "someone has paid for chips they haven't been given", which sends a dealer
|
|
// hunting for a miscount that never happened.
|
|
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
|
|
|
|
// Chips the house has taken. Off the table for good, like the discards, and
|
|
// for a better reason: the box is supposed to have them.
|
|
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
|
|
|
|
// Chips handed back over the rail. Off the table like the other two, and the
|
|
// one the dealer can point at when somebody asks where the money went.
|
|
const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0));
|
|
|
|
const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn);
|
|
return {
|
|
cashIn: Money.round(cashIn),
|
|
chipsInPlay: Money.round(chipsInPlay),
|
|
discarded,
|
|
raked,
|
|
paidOut,
|
|
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;
|
|
}
|