New /casino hub page (Texas Hold'em live, other table games marked coming soon) and /casino/texasholdem: a drag-and-drop seating table with auto-rotating blinds, per-seat chip/dollar contribution tracking, and a side-pot resolution wizard that asks who won and who's next until every dollar in the pot (including dead money from folds) is assigned. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
79 lines
3.2 KiB
JavaScript
79 lines
3.2 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);
|
|
},
|
|
|
|
// 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;
|
|
}
|