Dealing guides are consistent that the most common side-pot error isn't miscounting the chips, it's failing to announce the breakdown clearly before the showdown. The tool had the arithmetic right but only revealed it after the hand, one layer at a time, as the wizard asked who won each. The breakdown is now visible while the hand is still running: as soon as an all-in splits the money, the action bar lists the main pot and each side pot with the amount and who can win it, and the wizard repeats it under "Announce before the showdown" before asking for a winner. A hand with a single undivided pot shows nothing, so the line means something when it appears. Chips nobody could call are called what they are. Money above the largest live contribution was never contested — usually an overbet by someone who then folded — so it's listed as "Uncalled, back to X" rather than padding a side pot nobody can win. The wizard's leftover warning says the same thing instead of telling the dealer to un-fold someone to win it, which was the wrong advice for chips that should simply be pushed back. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
152 lines
6.5 KiB
JavaScript
152 lines
6.5 KiB
JavaScript
// 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: 0.004,
|
|
|
|
// 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) {
|
|
const live = players.filter(p => p.contribution > this.EPSILON);
|
|
if (live.length === 0) return [];
|
|
|
|
const heights = [...new Set(live.map(p => Math.round(p.contribution * 100)))].sort((a, b) => a - b);
|
|
|
|
const layers = [];
|
|
let floor = 0;
|
|
heights.forEach(height => {
|
|
const band = height - floor;
|
|
const payers = live.filter(p => Math.round(p.contribution * 100) >= height);
|
|
const amount = band * payers.length;
|
|
const eligible = payers.filter(p => !p.folded).map(p => p.id);
|
|
|
|
// A band nobody can win isn't a pot — it's an overbet that never got
|
|
// called, and it goes back to whoever put it in. Announcing it as a side
|
|
// pot would overstate what's actually being played for.
|
|
if (amount > 0 && eligible.length > 0) {
|
|
layers.push({ amount: amount / 100, eligible });
|
|
}
|
|
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 = Math.round((previous.amount + layer.amount) * 100) / 100;
|
|
} 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.
|
|
// Anything above the biggest live contribution was never contested — most often an
|
|
// overbet by someone who then folded.
|
|
uncalledReturns(players) {
|
|
const contributing = players.filter(p => p.contribution > this.EPSILON);
|
|
const liveCents = contributing.filter(p => !p.folded).map(p => Math.round(p.contribution * 100));
|
|
|
|
// 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 (liveCents.length === 0) return [];
|
|
|
|
const cap = Math.max(...liveCents);
|
|
|
|
return contributing
|
|
.map(p => ({ id: p.id, amount: (Math.round(p.contribution * 100) - cap) / 100 }))
|
|
.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: Math.round((e.remaining - used) * 100) / 100 };
|
|
});
|
|
|
|
payoutPool = Math.round(payoutPool * 100) / 100;
|
|
const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
|
|
let leftoverCents = Math.round(payoutPool * 100) - 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: w.amountCents / 100 }));
|
|
const remainingContenders = this.getContenders(updatedEntries);
|
|
|
|
return {
|
|
payoutPool,
|
|
capApplied: cap,
|
|
perWinner: result,
|
|
updatedEntries,
|
|
remainingContenders,
|
|
};
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = PotCalculator;
|
|
}
|