// 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; }