// Pure side-pot math. No DOM, no globals besides the PotCalculator namespace, so the // dealer's payout logic can be reasoned about (and unit tested) on its own. // // A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount // that player put in the pot and is whittled down round by round as money gets paid out. const PotCalculator = { EPSILON: Money.EPSILON, // Builds the initial entry list from raw contributions, keeping folded players in // (their chips are still "dead money" owed to whoever wins) while excluding anyone // who put in nothing at all. buildEntries(players) { return players .filter(p => p.contribution > this.EPSILON) .map(p => ({ id: p.id, remaining: p.contribution, folded: !!p.folded })); }, totalPot(entries) { return entries.reduce((sum, e) => sum + e.remaining, 0); }, // Players still eligible to be picked as a winner for the money that's left. getContenders(entries) { return entries.filter(e => !e.folded && e.remaining > this.EPSILON); }, // Splits the money on the table into the main pot and any side pots, with who can // win each. Read-only — this is what a dealer says out loud before the showdown // ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the // announcement that goes missing far more often than the arithmetic goes wrong. // // Every distinct all-in amount opens a new layer: each player pays into a layer up // to their own contribution, and only players who reached that height can win it. // Folded money stays in — it's dead money owed to whoever wins the layer it fell in. layers(players) { // Uncalled chips were never played for, so they come off the top before any // pot is built. Deriving the pots from what's left is what keeps this function // and uncalledReturns() from disagreeing about the same money — they used to // decide "uncontested" separately, and an overbet that its own bettor was // still live for got announced as a side pot only they could win. const returned = new Map(this.uncalledReturns(players).map(r => [r.id, Money.toCents(r.amount)])); const live = players .map(p => ({ ...p, cents: Money.toCents(p.contribution) - (returned.get(p.id) || 0) })) .filter(p => p.cents > 0); if (live.length === 0) return []; const heights = [...new Set(live.map(p => p.cents))].sort((a, b) => a - b); const layers = []; let floor = 0; heights.forEach(height => { const band = height - floor; const payers = live.filter(p => p.cents >= height); const amount = band * payers.length; const eligible = payers.filter(p => !p.folded).map(p => p.id); if (amount > 0) { if (eligible.length > 0) { layers.push({ amount: Money.fromCents(amount), eligible }); } else if (layers.length > 0) { // Everyone who reached this height folded, so it isn't a pot of its // own — it's dead money owed to whoever wins the layer underneath. // Dropping it would quietly shrink the announced pot. const below = layers[layers.length - 1]; below.amount = Money.round(below.amount + Money.fromCents(amount)); } // With no layer underneath either, nobody in the hand is live at all. // That's a fold flag the dealer needs to fix, not a payout to guess at. } floor = height; }); // Consecutive layers with the same set of eligible players are one pot to a // dealer — nobody announces two side pots between the same two people. const merged = []; layers.forEach(layer => { const previous = merged[merged.length - 1]; const sameEligibility = previous && previous.eligible.length === layer.eligible.length && previous.eligible.every(id => layer.eligible.includes(id)); if (sameEligibility) { previous.amount = Money.round(previous.amount + layer.amount); } else { merged.push({ ...layer }); } }); return merged.map((layer, index) => ({ ...layer, label: index === 0 ? "Main pot" : `Side pot ${index}`, })); }, // Chips nobody could call, which the dealer pushes back rather than into the pot. // // A bet is only called up to what somebody *else* was willing to put in, so the // ceiling for each player is the largest contribution among everyone but them. // Measuring against the whole table instead — including the bettor's own chips — // makes the biggest bet its own yardstick, so the one case this exists for (you // shove, a short stack calls for less) comes back empty. uncalledReturns(players) { const contributing = players.filter(p => p.contribution > this.EPSILON); // Nobody live at all means the fold flags are wrong, not that every chip on // the table was uncalled. That's a correction for the dealer to make, so say // nothing here and let the wizard ask them to fix it. if (contributing.filter(p => !p.folded).length === 0) return []; const cents = contributing.map(p => Money.toCents(p.contribution)); return contributing .map((p, index) => { const others = cents.filter((_, i) => i !== index); const cap = others.length > 0 ? Math.max(...others) : 0; return { id: p.id, amount: Money.fromCents(cents[index] - cap) }; }) .filter(r => r.amount > this.EPSILON); }, // Awards the current pot layer to `winnerIds` (one id, or several for a split/tie). // The payout is capped at the smallest remaining contribution among the winners — // that's the classic side-pot rule: an all-in player can only win what everyone at // the table matched, and the excess rolls forward to the next round. resolveRound(entries, winnerIds) { const winners = entries.filter(e => winnerIds.includes(e.id) && e.remaining > this.EPSILON); if (winners.length === 0) { throw new Error("No valid winners selected for this round."); } const cap = Math.min(...winners.map(w => w.remaining)); let payoutPool = 0; const updatedEntries = entries.map(e => { if (e.remaining <= this.EPSILON) return e; const used = Math.min(e.remaining, cap); payoutPool += used; return { ...e, remaining: Money.round(e.remaining - used) }; }); payoutPool = Money.round(payoutPool); const baseShareCents = Math.floor(Money.toCents(payoutPool) / winners.length); let leftoverCents = Money.toCents(payoutPool) - baseShareCents * winners.length; const perWinner = winnerIds .filter(id => winners.some(w => w.id === id)) .map(id => ({ id, amountCents: baseShareCents })); // Odd cents (can't split evenly) go one at a time to the winners, in the order // they were selected, so nothing is ever lost to rounding. let i = 0; while (leftoverCents > 0 && perWinner.length > 0) { perWinner[i % perWinner.length].amountCents += 1; leftoverCents--; i++; } const result = perWinner.map(w => ({ id: w.id, amount: Money.fromCents(w.amountCents) })); const remainingContenders = this.getContenders(updatedEntries); return { payoutPool, capApplied: cap, perWinner: result, updatedEntries, remainingContenders, }; }, }; if (typeof module !== "undefined" && module.exports) { module.exports = PotCalculator; }