Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js
T
jheapsandClaude Opus 5 c1a484b97a Pay down the Hold'em dealer's tech debt and give it a test suite
Every module in the dealer ends with a `module.exports` guard, so they were all
written to be testable, and none of them were ever tested. The correctness fixes
that went in recently were checked by hand in a browser console that has since
been closed. This puts a real safety net under them and clears out the
duplication that made those bugs possible in the first place.

Tests (58, ~300ms, no dependencies)

Node's built-in runner, no package.json and no install step: the modules are
plain script-tag files, so `load.js` just requires them and puts them on
globalThis under the names they use to find each other in the page.

The randomised suite is the part that matters. Every bug found in this tool so
far has been shape-dependent — blinds posted to empty chairs, the button
skipping players, heads-up acting out of order — and none of them show up on the
full six-handed table anyone would write a fixed example for. It deals 1000
hands per property across random seat counts, empty chairs, stack depths and
dealer input, checking that chips are never created or destroyed, that the pot
breakdown plus uncalled returns equals the money in, that paying out settles to
the cent, and that the button and blinds always land on real players. The
generator is seeded so a failure reproduces instead of flaking.

One implementation each

- `Math.round(x * 100) / 100` was written out 16 times across 6 files. It's
  `Money.round` / `toCents` / `fromCents` now, in the module both pages already
  load first.
- The chip epsilon was declared twice with the same value under two names
  (`PotCalculator.EPSILON`, `Betting.EPSILON_CHIPS`). Both now point at
  `Money.EPSILON`.
- `Betting.seatAt` and `HoldemState.nextSeatIndex` were the same modular
  arithmetic written twice. Walking the seats is exactly where the button, the
  blinds and the action drifted apart from each other before, so there is one
  of it now.
- The undo snapshot/restore dance appeared three times in main.js, each one
  re-deleting every key on `state` in place. It's `snapshotState` /
  `restoreState` once, with the reason it can't just reassign written down.

A bug the tests found

`layers()` and `uncalledReturns()` both decide what money was never contested,
separately, and disagreed. `uncalledReturns` measured each player against the
biggest live contribution *including their own*, which makes the biggest bet its
own yardstick — so the one case it exists for came back empty, and `layers()`
announced the overbet as a side pot only the bettor could win.

With Ann shoving 200 into Ben's 60, the dealer was told "main pot 4, side pot 3,
side pot 116, side pot 140" — four pots, one of them fictional. It now reads
"main pot 123, uncalled 140 back to Ann", which is what a dealer says out loud.
Layers are derived from contributions with the uncalled money already removed,
so the two can't disagree again. A band every payer folded out of is now merged
into the pot below rather than dropped, which was quietly shrinking the
announced pot in another edge case.

Validated with Playwright against a real hand on both surfaces — the table's
live pot readout and Quick Split's all-in mode — plus undo, with no console
errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:00:36 -06:00

174 lines
7.9 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);
},
// 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;
}