The pot wizard printed a figure beside every name under the words "up to". It was that player's own remaining contribution, which is not what winning pays them, and the gap was large and in the wrong direction. Three players in for $250 each: all three rows read "up to $250" while the prompt directly above them said "$765 is in play" — and $765 is what the winner was handed. A dealer who trusted the row would have short-paid by two thirds. With a real side pot it was worse in a more confusing way. The breakdown box at the top of the same dialog announced "Main pot $765 · all 3", and Bob's row three lines under it said "up to $250". One dialog, two numbers for the same money, and the wrong one next to the checkbox the dealer actually ticks. resolveRound caps the pool at the winner's own remaining contribution and then takes min(remaining, cap) from every entry, dead money included — so the ceiling is that sum, and PotCalculator.ceilingFor computes it. Bob's row now says $765, which is the main pot, which is what the breakdown box three lines above has been saying all along. It is also only said where it binds. A ceiling equal to everything on the table is the prompt's own figure repeated once per row — redundant data-ink, and on a pot everybody covered that was all it ever was. It now appears on exactly the rows where picking that winner leaves a side pot still to award, which is the one time a dealer needs telling. The flat pot's three rows carry a name and nothing else; the side pot's carries one number, on Bob. The test that matters is not that the ceiling equals a figure written out by hand but that it equals what resolveRound pays, so every case checks the label against the function that moves the chips — across seven shapes of pot, ragged contributions and cent-sized stakes included. 1082 tests pass; the 418-audit sweep is clean. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
200 lines
9.4 KiB
JavaScript
200 lines
9.4 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: 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);
|
|
},
|
|
|
|
// The most this player can be handed if they win the round outright.
|
|
//
|
|
// resolveRound caps the pool at the winner's own remaining contribution and then
|
|
// takes min(remaining, cap) from every entry, dead money included — so the ceiling
|
|
// is that sum. It is not the player's own contribution, which is what the wizard
|
|
// used to print beside each name under the words "up to": with three players in
|
|
// for $250 each the rows all read "up to $250" while the prompt above them said
|
|
// $765 was in play, and $765 is what the winner was actually paid. A dealer who
|
|
// trusted the row would have short-paid by two thirds.
|
|
ceilingFor(entries, id) {
|
|
const winner = entries.find(e => e.id === id);
|
|
if (!winner || winner.remaining <= this.EPSILON) return 0;
|
|
return Money.round(entries.reduce(
|
|
(sum, e) => sum + Math.min(e.remaining, winner.remaining), 0));
|
|
},
|
|
|
|
// The same number, but only when it is worth saying: a ceiling equal to everything
|
|
// on the table is the prompt's own figure repeated once per row, which is ink that
|
|
// carries no data. It appears exactly when somebody is in for less than the rest —
|
|
// which is the one time a dealer needs to be told, because it is the hand where
|
|
// picking that winner leaves a side pot still to award.
|
|
bindingCeilingFor(entries, id) {
|
|
const ceiling = this.ceilingFor(entries, id);
|
|
return ceiling < this.totalPot(entries) - this.EPSILON ? ceiling : null;
|
|
},
|
|
|
|
// 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;
|
|
}
|