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
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
# Unit tests for the Texas Hold'em dealer's money logic.
|
||||||
|
#
|
||||||
|
# The betting engine, the side-pot calculator and the table state are plain script-tag
|
||||||
|
# modules with no build step, so they run directly under Node's built-in test runner —
|
||||||
|
# no package.json, no install step, nothing to keep up to date.
|
||||||
|
|
||||||
|
name: Hold'em tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ "master" ]
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Run Hold'em unit tests
|
||||||
|
run: node --test tests/holdem/*.test.js
|
||||||
@@ -35,7 +35,7 @@ const AddChips = {
|
|||||||
quickAmounts() {
|
quickAmounts() {
|
||||||
const base = this.baseAmount();
|
const base = this.baseAmount();
|
||||||
return [
|
return [
|
||||||
{ label: "½ buy-in", value: Math.round(base / 2 * 100) / 100 },
|
{ label: "½ buy-in", value: Money.round(base / 2) },
|
||||||
{ label: "Buy-in", value: base },
|
{ label: "Buy-in", value: base },
|
||||||
{ label: "2 buy-ins", value: base * 2 },
|
{ label: "2 buy-ins", value: base * 2 },
|
||||||
];
|
];
|
||||||
@@ -83,7 +83,7 @@ const AddChips = {
|
|||||||
confirm() {
|
confirm() {
|
||||||
const amount = Math.max(0, Number(document.getElementById("addChipsInput").value) || 0);
|
const amount = Math.max(0, Number(document.getElementById("addChipsInput").value) || 0);
|
||||||
this.close();
|
this.close();
|
||||||
if (amount > 0) this.onConfirm(this.seatIndex, Math.round(amount * 100) / 100);
|
if (amount > 0) this.onConfirm(this.seatIndex, Money.round(amount));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const Betting = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
round(amount) {
|
round(amount) {
|
||||||
return Math.round((Number(amount) || 0) * 100) / 100;
|
return Money.round(amount);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Reading the table ----
|
// ---- Reading the table ----
|
||||||
@@ -87,10 +87,11 @@ const Betting = {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Which chair sits `step` places along in the direction of play. The table owns
|
||||||
|
// this — walking the seats is exactly where the button, the blinds and the action
|
||||||
|
// used to drift apart from each other, so there is one implementation of it.
|
||||||
seatAt(state, fromIndex, step) {
|
seatAt(state, fromIndex, step) {
|
||||||
const count = state.seatCount;
|
return HoldemState.nextSeatIndex(state, fromIndex, step);
|
||||||
const delta = state.direction === "counterclockwise" ? -step : step;
|
|
||||||
return ((fromIndex + delta) % count + count) % count;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// A street is done when everyone still live has either matched the bet or has
|
// A street is done when everyone still live has either matched the bet or has
|
||||||
@@ -230,7 +231,7 @@ const Betting = {
|
|||||||
return this.liveSeats(state).filter(s => this.stackOf(state, s.player) > this.EPSILON_CHIPS);
|
return this.liveSeats(state).filter(s => this.stackOf(state, s.player) > this.EPSILON_CHIPS);
|
||||||
},
|
},
|
||||||
|
|
||||||
EPSILON_CHIPS: 0.004,
|
EPSILON_CHIPS: Money.EPSILON,
|
||||||
|
|
||||||
canStillBet(state) {
|
canStillBet(state) {
|
||||||
return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1;
|
return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1;
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const state = HoldemState.load();
|
const state = HoldemState.load();
|
||||||
|
|
||||||
|
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
||||||
|
// by reference to every module at mount time, so it has to be refilled in place —
|
||||||
|
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||||
|
// the old object and quietly ignoring the undo.
|
||||||
|
const snapshotState = () => JSON.parse(JSON.stringify(state));
|
||||||
|
|
||||||
|
const restoreState = snapshot => {
|
||||||
|
Object.keys(state).forEach(key => delete state[key]);
|
||||||
|
Object.assign(state, snapshot);
|
||||||
|
HoldemState.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
};
|
||||||
|
|
||||||
// A gesture on a seat is a shortcut for something the dealer could do the long
|
// A gesture on a seat is a shortcut for something the dealer could do the long
|
||||||
// way, so each one is undoable the same way the header buttons are.
|
// way, so each one is undoable the same way the header buttons are.
|
||||||
const seatAction = (index, message, mutate) => {
|
const seatAction = (index, message, mutate) => {
|
||||||
const snapshot = JSON.parse(JSON.stringify(state));
|
const snapshot = snapshotState();
|
||||||
mutate(state.seats[index], index);
|
mutate(state.seats[index], index);
|
||||||
HoldemState.save(state);
|
HoldemState.save(state);
|
||||||
Render.renderAll();
|
Render.renderAll();
|
||||||
@@ -12,10 +25,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
Toast.show(message, {
|
Toast.show(message, {
|
||||||
actionLabel: "Undo",
|
actionLabel: "Undo",
|
||||||
onAction: () => {
|
onAction: () => {
|
||||||
Object.keys(state).forEach(key => delete state[key]);
|
restoreState(snapshot);
|
||||||
Object.assign(state, snapshot);
|
|
||||||
HoldemState.save(state);
|
|
||||||
Render.renderAll();
|
|
||||||
Toast.show("Undone.", { duration: 2500 });
|
Toast.show("Undone.", { duration: 2500 });
|
||||||
},
|
},
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
@@ -62,8 +72,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
|
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
|
||||||
// Chips in front and cash in are different questions — the stack moves
|
// Chips in front and cash in are different questions — the stack moves
|
||||||
// every hand, what they've handed over only moves when they buy.
|
// every hand, what they've handed over only moves when they buy.
|
||||||
player.stack = Math.round((Betting.stackOf(state, player) + amount) * 100) / 100;
|
player.stack = Money.round(Betting.stackOf(state, player) + amount);
|
||||||
player.buyIns = Math.round(((Number(player.buyIns) || 0) + amount) * 100) / 100;
|
player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -104,7 +114,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
resolvePot: openPotWizard,
|
resolvePot: openPotWizard,
|
||||||
|
|
||||||
beginAction: label => {
|
beginAction: label => {
|
||||||
actionHistory.push({ label, snapshot: JSON.parse(JSON.stringify(state)) });
|
actionHistory.push({ label, snapshot: snapshotState() });
|
||||||
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -113,10 +123,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
undoLast: () => {
|
undoLast: () => {
|
||||||
const step = actionHistory.pop();
|
const step = actionHistory.pop();
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
Object.keys(state).forEach(key => delete state[key]);
|
restoreState(step.snapshot);
|
||||||
Object.assign(state, step.snapshot);
|
|
||||||
hooks.save(state);
|
|
||||||
Render.renderAll();
|
|
||||||
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
|
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -163,7 +170,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// would notice a misclick. The snapshot is deliberately in-memory only: an
|
// would notice a misclick. The snapshot is deliberately in-memory only: an
|
||||||
// undo is for the mistake you just made, not one from last night's session.
|
// undo is for the mistake you just made, not one from last night's session.
|
||||||
const runUndoable = (message, mutate) => {
|
const runUndoable = (message, mutate) => {
|
||||||
const snapshot = JSON.parse(JSON.stringify(state));
|
const snapshot = snapshotState();
|
||||||
mutate();
|
mutate();
|
||||||
// Starting or settling a hand invalidates the per-action history: those
|
// Starting or settling a hand invalidates the per-action history: those
|
||||||
// snapshots describe a hand that's over. The whole-step Undo below replaces it.
|
// snapshots describe a hand that's over. The whole-step Undo below replaces it.
|
||||||
@@ -174,10 +181,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
Toast.show(message, {
|
Toast.show(message, {
|
||||||
actionLabel: "Undo",
|
actionLabel: "Undo",
|
||||||
onAction: () => {
|
onAction: () => {
|
||||||
Object.keys(state).forEach(key => delete state[key]);
|
restoreState(snapshot);
|
||||||
Object.assign(state, snapshot);
|
|
||||||
hooks.save(state);
|
|
||||||
Render.renderAll();
|
|
||||||
Toast.show("Undone.", { duration: 2500 });
|
Toast.show("Undone.", { duration: 2500 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
// Shared money formatting for every casino surface. Kept apart from the table's
|
// Shared money handling for every casino surface. Kept apart from the table's
|
||||||
// rendering so the quick-split calculator can pull in the formatter without dragging
|
// rendering so the quick-split calculator can pull it in without dragging the whole
|
||||||
// the whole seat/chip UI along with it.
|
// seat/chip UI along with it.
|
||||||
|
//
|
||||||
|
// Everything the dealer touches is money, and money is decimal, so every module used
|
||||||
|
// to carry its own `Math.round(x * 100) / 100`. That's one rounding rule written out
|
||||||
|
// sixteen times in six files, which is sixteen chances for one of them to drift.
|
||||||
|
// It lives here now, and the modules that do arithmetic call these instead.
|
||||||
const Money = {
|
const Money = {
|
||||||
|
// Half a cent. Chip comparisons need a tolerance: a stack that lands on
|
||||||
|
// 0.0000000001 after a split is empty as far as a dealer is concerned, and an
|
||||||
|
// exact `> 0` would treat that player as still having chips to bet.
|
||||||
|
EPSILON: 0.004,
|
||||||
|
|
||||||
|
// All money maths goes through cents, so 0.1 + 0.2 never reaches a payout.
|
||||||
|
toCents(amount) {
|
||||||
|
return Math.round((Number(amount) || 0) * 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
fromCents(cents) {
|
||||||
|
return cents / 100;
|
||||||
|
},
|
||||||
|
|
||||||
|
round(amount) {
|
||||||
|
return this.toCents(amount) / 100;
|
||||||
|
},
|
||||||
|
|
||||||
format(amount) {
|
format(amount) {
|
||||||
const value = Number(amount) || 0;
|
const value = Number(amount) || 0;
|
||||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount
|
// 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.
|
// that player put in the pot and is whittled down round by round as money gets paid out.
|
||||||
const PotCalculator = {
|
const PotCalculator = {
|
||||||
EPSILON: 0.004,
|
EPSILON: Money.EPSILON,
|
||||||
|
|
||||||
// Builds the initial entry list from raw contributions, keeping folded players in
|
// 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
|
// (their chips are still "dead money" owed to whoever wins) while excluding anyone
|
||||||
@@ -33,24 +33,39 @@ const PotCalculator = {
|
|||||||
// to their own contribution, and only players who reached that height can win it.
|
// 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.
|
// Folded money stays in — it's dead money owed to whoever wins the layer it fell in.
|
||||||
layers(players) {
|
layers(players) {
|
||||||
const live = players.filter(p => p.contribution > this.EPSILON);
|
// 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 [];
|
if (live.length === 0) return [];
|
||||||
|
|
||||||
const heights = [...new Set(live.map(p => Math.round(p.contribution * 100)))].sort((a, b) => a - b);
|
const heights = [...new Set(live.map(p => p.cents))].sort((a, b) => a - b);
|
||||||
|
|
||||||
const layers = [];
|
const layers = [];
|
||||||
let floor = 0;
|
let floor = 0;
|
||||||
heights.forEach(height => {
|
heights.forEach(height => {
|
||||||
const band = height - floor;
|
const band = height - floor;
|
||||||
const payers = live.filter(p => Math.round(p.contribution * 100) >= height);
|
const payers = live.filter(p => p.cents >= height);
|
||||||
const amount = band * payers.length;
|
const amount = band * payers.length;
|
||||||
const eligible = payers.filter(p => !p.folded).map(p => p.id);
|
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
|
if (amount > 0) {
|
||||||
// called, and it goes back to whoever put it in. Announcing it as a side
|
if (eligible.length > 0) {
|
||||||
// pot would overstate what's actually being played for.
|
layers.push({ amount: Money.fromCents(amount), eligible });
|
||||||
if (amount > 0 && eligible.length > 0) {
|
} else if (layers.length > 0) {
|
||||||
layers.push({ amount: amount / 100, eligible });
|
// 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;
|
floor = height;
|
||||||
});
|
});
|
||||||
@@ -66,7 +81,7 @@ const PotCalculator = {
|
|||||||
previous.eligible.every(id => layer.eligible.includes(id));
|
previous.eligible.every(id => layer.eligible.includes(id));
|
||||||
|
|
||||||
if (sameEligibility) {
|
if (sameEligibility) {
|
||||||
previous.amount = Math.round((previous.amount + layer.amount) * 100) / 100;
|
previous.amount = Money.round(previous.amount + layer.amount);
|
||||||
} else {
|
} else {
|
||||||
merged.push({ ...layer });
|
merged.push({ ...layer });
|
||||||
}
|
}
|
||||||
@@ -79,21 +94,28 @@ const PotCalculator = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Chips nobody could call, which the dealer pushes back rather than into the pot.
|
// 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.
|
// 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) {
|
uncalledReturns(players) {
|
||||||
const contributing = players.filter(p => p.contribution > this.EPSILON);
|
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
|
// 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
|
// 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.
|
// nothing here and let the wizard ask them to fix it.
|
||||||
if (liveCents.length === 0) return [];
|
if (contributing.filter(p => !p.folded).length === 0) return [];
|
||||||
|
|
||||||
const cap = Math.max(...liveCents);
|
const cents = contributing.map(p => Money.toCents(p.contribution));
|
||||||
|
|
||||||
return contributing
|
return contributing
|
||||||
.map(p => ({ id: p.id, amount: (Math.round(p.contribution * 100) - cap) / 100 }))
|
.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);
|
.filter(r => r.amount > this.EPSILON);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -113,12 +135,12 @@ const PotCalculator = {
|
|||||||
if (e.remaining <= this.EPSILON) return e;
|
if (e.remaining <= this.EPSILON) return e;
|
||||||
const used = Math.min(e.remaining, cap);
|
const used = Math.min(e.remaining, cap);
|
||||||
payoutPool += used;
|
payoutPool += used;
|
||||||
return { ...e, remaining: Math.round((e.remaining - used) * 100) / 100 };
|
return { ...e, remaining: Money.round(e.remaining - used) };
|
||||||
});
|
});
|
||||||
|
|
||||||
payoutPool = Math.round(payoutPool * 100) / 100;
|
payoutPool = Money.round(payoutPool);
|
||||||
const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
|
const baseShareCents = Math.floor(Money.toCents(payoutPool) / winners.length);
|
||||||
let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length;
|
let leftoverCents = Money.toCents(payoutPool) - baseShareCents * winners.length;
|
||||||
|
|
||||||
const perWinner = winnerIds
|
const perWinner = winnerIds
|
||||||
.filter(id => winners.some(w => w.id === id))
|
.filter(id => winners.some(w => w.id === id))
|
||||||
@@ -133,7 +155,7 @@ const PotCalculator = {
|
|||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = perWinner.map(w => ({ id: w.id, amount: w.amountCents / 100 }));
|
const result = perWinner.map(w => ({ id: w.id, amount: Money.fromCents(w.amountCents) }));
|
||||||
const remainingContenders = this.getContenders(updatedEntries);
|
const remainingContenders = this.getContenders(updatedEntries);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ const QuickSplit = {
|
|||||||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
|
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
|
||||||
|
|
||||||
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||||
addRow("On the table", Math.round(total * 100) / 100, "", "unevenPotTotal");
|
addRow("On the table", Money.round(total), "", "unevenPotTotal");
|
||||||
},
|
},
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
const SplitCalculator = {
|
const SplitCalculator = {
|
||||||
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
|
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
|
||||||
toCents(amount) {
|
toCents(amount) {
|
||||||
return Math.round((Number(amount) || 0) * 100);
|
return Money.toCents(amount);
|
||||||
},
|
},
|
||||||
|
|
||||||
split(potAmount, ways, chipAmount) {
|
split(potAmount, ways, chipAmount) {
|
||||||
@@ -40,11 +40,11 @@ const SplitCalculator = {
|
|||||||
|
|
||||||
// Sanity check for the UI: what the split actually hands out has to equal the pot.
|
// Sanity check for the UI: what the split actually hands out has to equal the pot.
|
||||||
accountedFor(result) {
|
accountedFor(result) {
|
||||||
return (
|
return Money.fromCents(
|
||||||
Math.round(result.each * 100) * result.players +
|
Money.toCents(result.each) * result.players +
|
||||||
Math.round(result.chip * 100) * result.oddChips +
|
Money.toCents(result.chip) * result.oddChips +
|
||||||
Math.round(result.dust * 100)
|
Money.toCents(result.dust)
|
||||||
) / 100;
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -285,8 +285,8 @@ const HoldemState = {
|
|||||||
return Array.from(totals.values())
|
return Array.from(totals.values())
|
||||||
.map(t => {
|
.map(t => {
|
||||||
const seat = seatById.get(t.id);
|
const seat = seatById.get(t.id);
|
||||||
const boughtIn = seat ? Math.round((Number(seat.buyIns) || 0) * 100) / 100 : 0;
|
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
|
||||||
const chipsNow = seat ? Math.round((Number(seat.stack) || 0) * 100) / 100 : 0;
|
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
|
||||||
// Chips already pushed into the hand in progress are still this
|
// Chips already pushed into the hand in progress are still this
|
||||||
// player's money until it's won. Leaving them out makes the cash
|
// player's money until it's won. Leaving them out makes the cash
|
||||||
// position disagree with the hand log for as long as a hand is open —
|
// position disagree with the hand log for as long as a hand is open —
|
||||||
@@ -296,12 +296,12 @@ const HoldemState = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
in: Math.round(t.in * 100) / 100,
|
in: Money.round(t.in),
|
||||||
won: Math.round(t.won * 100) / 100,
|
won: Money.round(t.won),
|
||||||
net: Math.round((t.won - t.in) * 100) / 100,
|
net: Money.round(t.won - t.in),
|
||||||
boughtIn,
|
boughtIn,
|
||||||
chipsNow,
|
chipsNow,
|
||||||
cashPosition: Math.round((chipsNow + inFrontOfThem - boughtIn) * 100) / 100,
|
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.net - a.net);
|
.sort((a, b) => b.net - a.net);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Hold'em dealer tests
|
||||||
|
|
||||||
|
Unit tests for the money logic behind `/Casino/TexasHoldem` — the betting engine, the
|
||||||
|
side-pot calculator, the split calculator and the table state.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test tests/holdem/*.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
No install step, no `package.json`, no dependencies. The browser modules are plain
|
||||||
|
script-tag files that each end with a `module.exports` guard, so Node can require them
|
||||||
|
as-is; `load.js` puts them on `globalThis` under the same names they use to find each
|
||||||
|
other in the page, in the load order from `TexasHoldem.cshtml`.
|
||||||
|
|
||||||
|
## What's covered
|
||||||
|
|
||||||
|
| File | Covers |
|
||||||
|
| --- | --- |
|
||||||
|
| `potCalculator.test.js` | Main and side pots, dead money, uncalled bets, odd-cent distribution |
|
||||||
|
| `betting.test.js` | Blinds, street completion, raises reopening the action, all-in run-outs |
|
||||||
|
| `state.test.js` | Button rotation, which seats post blinds, session standings |
|
||||||
|
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
|
||||||
|
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||||
|
|
||||||
|
## Why the randomised suite exists
|
||||||
|
|
||||||
|
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
|
||||||
|
to empty chairs, the button skipping past players, heads-up acting out of order, an
|
||||||
|
overbet announced as a side pot. None of them show up on a full six-handed table with
|
||||||
|
equal stacks, which is the table anyone writes a fixed example for.
|
||||||
|
|
||||||
|
`invariants.test.js` deals a thousand hands across random seat counts, random empty
|
||||||
|
chairs, random stack depths and random dealer input, and checks the things a dealer
|
||||||
|
would notice being wrong:
|
||||||
|
|
||||||
|
- chips are never created or destroyed during play
|
||||||
|
- the pot breakdown plus the uncalled returns always equal the money that went in
|
||||||
|
- paying the pot out settles it to the cent
|
||||||
|
- once uncalled chips are handed back, nobody is still betting above the whole table
|
||||||
|
- the button and both blinds always land on players who are actually in the hand
|
||||||
|
|
||||||
|
The generator is seeded, so a failure reproduces from the seed printed in the message
|
||||||
|
rather than being a flake to re-run.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
describe("Betting.beginHand", () => {
|
||||||
|
test("blinds are posted and the seat after the big blind acts first", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0, smallBlind: 1, bigBlind: 2 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].committed, 1, "small blind");
|
||||||
|
assert.equal(state.seats[2].committed, 2, "big blind");
|
||||||
|
assert.equal(state.currentBet, 2);
|
||||||
|
assert.equal(state.actingSeatIndex, 3, "under the gun");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heads-up, the button posts the small blind and acts first pre-flop", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[2].committed, 1, "the button is the small blind");
|
||||||
|
assert.equal(state.seats[5].committed, 2);
|
||||||
|
assert.equal(state.actingSeatIndex, 2, "the small blind acts first pre-flop heads-up");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heads-up, the big blind acts first once the flop is out", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 2);
|
||||||
|
Betting.check(state, 5);
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
|
||||||
|
assert.equal(state.street, "flop");
|
||||||
|
assert.equal(state.actingSeatIndex, 5, "the big blind is first to act after the flop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a blind bigger than a stack puts that player all-in without stalling the action", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0, smallBlind: 50, bigBlind: 100 });
|
||||||
|
state.seats[0].stack = 10; // can't cover the small blind
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.seats[0].stack, 0, "all-in for what they had");
|
||||||
|
assert.equal(state.seats[0].committed, 10);
|
||||||
|
assert.notEqual(state.actingSeatIndex, 0, "a player with no chips has nothing to decide");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("posting blinds never invents or destroys chips", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(chipsOnTable(state), before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Betting street completion", () => {
|
||||||
|
test("a street ends once everyone has matched the bet and had a turn", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
Betting.call(state, 3);
|
||||||
|
Betting.call(state, 0);
|
||||||
|
Betting.call(state, 1);
|
||||||
|
assert.equal(Betting.isStreetComplete(state), false, "the big blind still has an option");
|
||||||
|
|
||||||
|
Betting.check(state, 2);
|
||||||
|
assert.equal(Betting.isStreetComplete(state), true);
|
||||||
|
assert.equal(state.actingSeatIndex, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a raise reopens the action for everyone who had already called", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
Betting.call(state, 3);
|
||||||
|
Betting.call(state, 0);
|
||||||
|
assert.equal(state.seats[3].hasActed, true);
|
||||||
|
|
||||||
|
Betting.raiseTo(state, 1, 10);
|
||||||
|
assert.equal(state.seats[3].hasActed, false, "seat 3 owes a response again");
|
||||||
|
assert.equal(state.seats[0].hasActed, false);
|
||||||
|
assert.equal(state.currentBet, 10);
|
||||||
|
assert.equal(Betting.isStreetComplete(state), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the action skips a player who is all-in but still in the hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.seats[3].stack = 5;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
Betting.allIn(state, 3);
|
||||||
|
assert.equal(state.seats[3].stack, 0);
|
||||||
|
assert.notEqual(state.actingSeatIndex, 3);
|
||||||
|
// They are still live and can still win the pot.
|
||||||
|
assert.ok(Betting.liveSeats(state).some(s => s.index === 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing a street sweeps chips into hand totals and resets the bet", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 3);
|
||||||
|
Betting.call(state, 0);
|
||||||
|
Betting.call(state, 1);
|
||||||
|
Betting.check(state, 2);
|
||||||
|
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
|
||||||
|
assert.equal(state.currentBet, 0);
|
||||||
|
assert.equal(state.street, "flop");
|
||||||
|
assert.equal(chipsOnTable(state), before, "sweeping moves chips, it doesn't change the total");
|
||||||
|
state.seats.forEach(p => assert.equal(p.committed, 0));
|
||||||
|
assert.equal(Betting.handTotal(state.seats[2]), 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Betting all-in run-outs", () => {
|
||||||
|
test("with everyone all-in there is nobody left to ask", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 20, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.allIn(state, 2);
|
||||||
|
Betting.allIn(state, 0);
|
||||||
|
Betting.allIn(state, 1);
|
||||||
|
|
||||||
|
assert.equal(Betting.canStillBet(state), false);
|
||||||
|
assert.equal(Betting.isRunningOut(state), true, "the board just gets dealt out");
|
||||||
|
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
assert.equal(state.actingSeatIndex, null, "no phantom action on the flop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one player with chips against an all-in cannot bet alone", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.seats[1].stack = 5;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.fold(state, 2);
|
||||||
|
Betting.allIn(state, 0);
|
||||||
|
Betting.allIn(state, 1);
|
||||||
|
|
||||||
|
assert.equal(Betting.canStillBet(state), false);
|
||||||
|
assert.equal(Betting.isHandOver(state), false, "two players are still live for the showdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("everyone folding to one player ends the hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.fold(state, 3);
|
||||||
|
Betting.fold(state, 0);
|
||||||
|
Betting.fold(state, 1);
|
||||||
|
|
||||||
|
assert.equal(Betting.isHandOver(state), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Betting money handling", () => {
|
||||||
|
test("a player can never put in more than they have", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 30, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const moved = Betting.putIn(state, 0, 500);
|
||||||
|
assert.equal(moved, 30);
|
||||||
|
assert.equal(state.seats[0].stack, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("raise-to is what a dealer says out loud, not raise-by", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.raiseTo(state, 3, 10);
|
||||||
|
assert.equal(state.seats[3].committed, 10, "their street total is 10, not 10 on top");
|
||||||
|
assert.equal(state.seats[3].stack, 90);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the minimum raise has to match the last one", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(Betting.minRaiseTo(state), 4, "a big blind of 2 makes the first raise 4");
|
||||||
|
|
||||||
|
Betting.raiseTo(state, 3, 10);
|
||||||
|
assert.equal(Betting.minRaiseTo(state), 18, "an 8-chip raise means the next one is to 18");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("winnings land back in front of the player", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.creditWinnings(state, [{ id: state.seats[0].id, amount: 42.5 }]);
|
||||||
|
assert.equal(state.seats[0].stack, 142.5, "the button posts nothing three-handed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fractional blinds don't drift into floating-point dust", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 10, smallBlind: 0.1, bigBlind: 0.2 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 0); // button limps
|
||||||
|
Betting.call(state, 1); // small blind completes
|
||||||
|
Betting.check(state, 2); // big blind is happy
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), 30, "0.1 + 0.2 arithmetic didn't lose a chip");
|
||||||
|
assert.equal(Betting.potSoFar(state), 0.6);
|
||||||
|
state.seats.forEach(p => assert.equal(p.stack, 9.8));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Randomised whole-hand play against the invariants a dealer would notice being broken.
|
||||||
|
//
|
||||||
|
// These ran once, by hand, in a browser console while the betting engine was being
|
||||||
|
// fixed, and then went away with the tab. The bugs they caught (blinds posted to empty
|
||||||
|
// chairs, the button skipping players, heads-up acting out of order) are all cases you
|
||||||
|
// only hit on a particular table shape, so a fixed set of examples misses them. Keeping
|
||||||
|
// the generator means the next change gets checked against the same thousand tables.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, PotCalculator, HoldemState, Money, makeTable, chipsOnTable, contributions } = require("./load");
|
||||||
|
|
||||||
|
// Deterministic PRNG: a failure has to be reproducible from the seed printed with it,
|
||||||
|
// otherwise a randomised suite is just a flaky one.
|
||||||
|
function rng(seed) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s * 1664525 + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plays one hand to completion with random but legal-ish dealer input.
|
||||||
|
function playHand(random) {
|
||||||
|
const seatCount = 2 + Math.floor(random() * 9);
|
||||||
|
const seatIndexes = [...Array(seatCount).keys()];
|
||||||
|
const seated = seatIndexes.filter(() => random() < 0.7);
|
||||||
|
if (seated.length < 2) seated.push(...seatIndexes.filter(i => !seated.includes(i)).slice(0, 2 - seated.length));
|
||||||
|
|
||||||
|
const bigBlind = [2, 5, 0.5][Math.floor(random() * 3)];
|
||||||
|
const state = makeTable({
|
||||||
|
seatCount,
|
||||||
|
seated,
|
||||||
|
stack: 0,
|
||||||
|
smallBlind: bigBlind / 2,
|
||||||
|
bigBlind,
|
||||||
|
dealerSeatIndex: Math.floor(random() * seatCount),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Varied stack depths, so short stacks and side pots actually happen.
|
||||||
|
seated.forEach(i => {
|
||||||
|
state.seats[i].stack = Money.round(bigBlind * (1 + Math.floor(random() * 60)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
let guard = 0;
|
||||||
|
while (!Betting.isHandOver(state) && state.street !== "done" && guard++ < 400) {
|
||||||
|
if (state.actingSeatIndex === null) {
|
||||||
|
if (state.street === "river" || !Betting.canStillBet(state)) break;
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = state.actingSeatIndex;
|
||||||
|
const player = state.seats[index];
|
||||||
|
const toCall = Betting.toCall(state, player);
|
||||||
|
const roll = random();
|
||||||
|
|
||||||
|
if (roll < 0.15) Betting.fold(state, index);
|
||||||
|
else if (roll < 0.25) Betting.allIn(state, index);
|
||||||
|
else if (toCall > 0) Betting.call(state, index);
|
||||||
|
else if (roll < 0.75) Betting.check(state, index);
|
||||||
|
else Betting.raiseTo(state, index, Betting.minRaiseTo(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
return { state, startingChips, seated };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("randomised hands hold the dealer's invariants", () => {
|
||||||
|
const HANDS = 1000;
|
||||||
|
|
||||||
|
test(`${HANDS} hands never create or destroy a chip`, () => {
|
||||||
|
const random = rng(20260901);
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const { state, startingChips } = playHand(random);
|
||||||
|
assert.equal(
|
||||||
|
chipsOnTable(state),
|
||||||
|
startingChips,
|
||||||
|
`hand ${hand}: chips on the table changed during play`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${HANDS} hands: pots plus uncalled returns always equal the money in`, () => {
|
||||||
|
const random = rng(778899);
|
||||||
|
let sidePotHands = 0;
|
||||||
|
let returnHands = 0;
|
||||||
|
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const { state } = playHand(random);
|
||||||
|
const players = contributions(state);
|
||||||
|
const inThePot = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||||
|
if (inThePot <= 0) continue;
|
||||||
|
|
||||||
|
// Fold flags can leave nobody live, which the calculator deliberately
|
||||||
|
// refuses to guess at — that's a dealer correction, not an invariant.
|
||||||
|
if (players.every(p => p.folded)) continue;
|
||||||
|
|
||||||
|
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
|
||||||
|
const returned = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
Money.toCents(layered + returned),
|
||||||
|
Money.toCents(inThePot),
|
||||||
|
`hand ${hand}: pot breakdown doesn't add up to what went in`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (PotCalculator.layers(players).length > 1) sidePotHands++;
|
||||||
|
if (returned > 0) returnHands++;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(sidePotHands > 20, `expected side pots to occur; saw ${sidePotHands}`);
|
||||||
|
assert.ok(returnHands > 20, `expected uncalled bets to occur; saw ${returnHands}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${HANDS} hands: what's left after returns is money somebody actually matched`, () => {
|
||||||
|
// The rule the pot breakdown rests on: you are only called up to what somebody
|
||||||
|
// else was willing to put in. Once the uncalled chips are handed back, no
|
||||||
|
// player should still be sitting above every other player at the table.
|
||||||
|
//
|
||||||
|
// A single-eligible pot is *not* the thing to look for here — that's legitimate
|
||||||
|
// when a player called and then folded, leaving dead money one contender
|
||||||
|
// collects. The bug this guards against is subtler: chips nobody ever matched
|
||||||
|
// being dressed up as a pot to play for.
|
||||||
|
const random = rng(13579);
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const { state } = playHand(random);
|
||||||
|
const players = contributions(state);
|
||||||
|
if (players.every(p => p.folded)) continue;
|
||||||
|
|
||||||
|
const returns = PotCalculator.uncalledReturns(players);
|
||||||
|
assert.ok(returns.length <= 1, `hand ${hand}: only the biggest bet can go uncalled`);
|
||||||
|
|
||||||
|
returns.forEach(r => {
|
||||||
|
const player = players.find(p => p.id === r.id);
|
||||||
|
assert.ok(r.amount <= player.contribution + 0.001, `hand ${hand}: returned more than was put in`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const returned = new Map(returns.map(r => [r.id, r.amount]));
|
||||||
|
const capped = players.map(p => Money.toCents(p.contribution - (returned.get(p.id) || 0)));
|
||||||
|
|
||||||
|
capped.forEach((cents, index) => {
|
||||||
|
const others = capped.filter((_, i) => i !== index);
|
||||||
|
const mostAnyoneElsePaid = others.length ? Math.max(...others) : 0;
|
||||||
|
assert.ok(
|
||||||
|
cents <= mostAnyoneElsePaid,
|
||||||
|
`hand ${hand}: a player is still ${Money.fromCents(cents - mostAnyoneElsePaid)} ` +
|
||||||
|
`above everyone else after returns — that money was never matched`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${HANDS} hands: paying the pot out settles it exactly`, () => {
|
||||||
|
const random = rng(24680);
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const { state } = playHand(random);
|
||||||
|
const players = contributions(state);
|
||||||
|
if (players.every(p => p.folded)) continue;
|
||||||
|
|
||||||
|
// Take the uncalled money off first, the way the dealer would.
|
||||||
|
const returned = new Map(PotCalculator.uncalledReturns(players).map(r => [r.id, r.amount]));
|
||||||
|
const contested = players.map(p => ({
|
||||||
|
...p,
|
||||||
|
contribution: Money.round(p.contribution - (returned.get(p.id) || 0)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let entries = PotCalculator.buildEntries(contested);
|
||||||
|
const potBefore = PotCalculator.totalPot(entries);
|
||||||
|
let paidOut = 0;
|
||||||
|
let rounds = 0;
|
||||||
|
|
||||||
|
while (PotCalculator.getContenders(entries).length > 0 && rounds++ < 30) {
|
||||||
|
const contenders = PotCalculator.getContenders(entries);
|
||||||
|
const winner = contenders[Math.floor(random() * contenders.length)];
|
||||||
|
const result = PotCalculator.resolveRound(entries, [winner.id]);
|
||||||
|
paidOut += result.perWinner.reduce((s, w) => s + w.amount, 0);
|
||||||
|
entries = result.updatedEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
Money.toCents(paidOut),
|
||||||
|
Money.toCents(potBefore),
|
||||||
|
`hand ${hand}: paying out the pot left ${potBefore - paidOut} unaccounted for`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${HANDS} hands: the button and blinds land on real players`, () => {
|
||||||
|
const random = rng(112233);
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const seatCount = 2 + Math.floor(random() * 9);
|
||||||
|
const seated = [...Array(seatCount).keys()].filter(() => random() < 0.6);
|
||||||
|
if (seated.length < 2) continue;
|
||||||
|
|
||||||
|
const state = makeTable({ seatCount, seated, dealerSeatIndex: Math.floor(random() * seatCount) });
|
||||||
|
HoldemState.advanceButton(state);
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand, `hand ${hand}: button on an empty chair`);
|
||||||
|
|
||||||
|
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(state.seats[sbIndex].inHand, `hand ${hand}: small blind on an empty chair`);
|
||||||
|
assert.ok(state.seats[bbIndex].inHand, `hand ${hand}: big blind on an empty chair`);
|
||||||
|
assert.notEqual(sbIndex, bbIndex, `hand ${hand}: both blinds on one player`);
|
||||||
|
|
||||||
|
if (seated.length === 2) {
|
||||||
|
assert.ok(headsUp, `hand ${hand}: two players should be heads-up`);
|
||||||
|
assert.equal(sbIndex, state.dealerSeatIndex, `hand ${hand}: heads-up button posts the small blind`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Loads the dealer's browser modules into Node so they can be tested directly.
|
||||||
|
//
|
||||||
|
// The modules are plain script-tag files: each one declares a `const` namespace and
|
||||||
|
// ends with a `module.exports` guard. In the browser they find each other as globals,
|
||||||
|
// so the same names have to exist on `globalThis` here or `Betting` can't reach
|
||||||
|
// `HoldemState`. Load order matches the <script> order in TexasHoldem.cshtml.
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||||
|
|
||||||
|
function load(name) {
|
||||||
|
const mod = require(path.join(SRC, `${name}.js`));
|
||||||
|
globalThis[name.charAt(0).toUpperCase() + name.slice(1)] = mod;
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Money = load("money");
|
||||||
|
const PotCalculator = load("potCalculator");
|
||||||
|
const SplitCalculator = load("splitCalculator");
|
||||||
|
const Betting = load("betting");
|
||||||
|
|
||||||
|
// state.js exports `HoldemState`, which doesn't match its filename.
|
||||||
|
const HoldemState = require(path.join(SRC, "state.js"));
|
||||||
|
globalThis.HoldemState = HoldemState;
|
||||||
|
|
||||||
|
// ---- Table builders ----------------------------------------------------------
|
||||||
|
|
||||||
|
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||||
|
// in with `stack` chips each. Everything else is the app's own defaults, so a test
|
||||||
|
// that forgets to set a field gets whatever a real dealer would get.
|
||||||
|
function makeTable({ seatCount = 6, seated = null, stack = 100, smallBlind = 1, bigBlind = 2, dealerSeatIndex = 0 } = {}) {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, seatCount);
|
||||||
|
state.smallBlind = smallBlind;
|
||||||
|
state.bigBlind = bigBlind;
|
||||||
|
state.dealerSeatIndex = dealerSeatIndex;
|
||||||
|
|
||||||
|
const sitting = seated || state.seats.map((_, i) => i);
|
||||||
|
state.seats.forEach((player, index) => {
|
||||||
|
player.inHand = sitting.includes(index);
|
||||||
|
player.stack = sitting.includes(index) ? stack : null;
|
||||||
|
player.name = `P${index + 1}`;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total chips the table is accountable for: what's in front of everyone, plus what
|
||||||
|
// they've pushed in this street, plus what's already banked into the pot.
|
||||||
|
function chipsOnTable(state) {
|
||||||
|
return Money.round(
|
||||||
|
state.seats.reduce(
|
||||||
|
(sum, p) => sum + (Number(p.stack) || 0) + (Number(p.committed) || 0) + (Number(p.amountOverride) || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contributions in the shape PotCalculator wants them.
|
||||||
|
function contributions(state) {
|
||||||
|
return state.seats
|
||||||
|
.filter(p => p.inHand)
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
contribution: Betting.handTotal(p),
|
||||||
|
folded: p.folded,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
Money,
|
||||||
|
PotCalculator,
|
||||||
|
SplitCalculator,
|
||||||
|
Betting,
|
||||||
|
HoldemState,
|
||||||
|
makeTable,
|
||||||
|
chipsOnTable,
|
||||||
|
contributions,
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { PotCalculator } = require("./load");
|
||||||
|
|
||||||
|
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
|
||||||
|
|
||||||
|
describe("PotCalculator.layers", () => {
|
||||||
|
test("one pot when everyone put in the same", () => {
|
||||||
|
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("c", 50)]);
|
||||||
|
assert.equal(layers.length, 1);
|
||||||
|
assert.equal(layers[0].amount, 150);
|
||||||
|
assert.equal(layers[0].label, "Main pot");
|
||||||
|
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a short all-in opens a side pot only the deeper players can win", () => {
|
||||||
|
// a is all-in for 20, b and c keep betting to 60.
|
||||||
|
const layers = PotCalculator.layers([p("a", 20), p("b", 60), p("c", 60)]);
|
||||||
|
assert.equal(layers.length, 2);
|
||||||
|
|
||||||
|
assert.equal(layers[0].amount, 60); // 20 x 3
|
||||||
|
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
|
||||||
|
|
||||||
|
assert.equal(layers[1].amount, 80); // 40 x 2
|
||||||
|
assert.deepEqual(layers[1].eligible.sort(), ["b", "c"]);
|
||||||
|
assert.equal(layers[1].label, "Side pot 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("folded money stays in the pot as dead money but wins nothing", () => {
|
||||||
|
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("folder", 50, true)]);
|
||||||
|
assert.equal(layers.length, 1);
|
||||||
|
assert.equal(layers[0].amount, 150, "the folder's chips are still in the middle");
|
||||||
|
assert.ok(!layers[0].eligible.includes("folder"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("layers with the same eligible players are announced as one pot", () => {
|
||||||
|
// Two folders at different heights would each open a band, but a dealer
|
||||||
|
// announces one pot between the two players who can actually win it.
|
||||||
|
const layers = PotCalculator.layers([
|
||||||
|
p("a", 100),
|
||||||
|
p("b", 100),
|
||||||
|
p("f1", 20, true),
|
||||||
|
p("f2", 40, true),
|
||||||
|
]);
|
||||||
|
const eligibleSets = layers.map(l => l.eligible.slice().sort().join(","));
|
||||||
|
assert.deepEqual(eligibleSets, ["a,b"], "one announceable pot, not three");
|
||||||
|
assert.equal(layers[0].amount, 260);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("layer amounts always sum to the contested money", () => {
|
||||||
|
const players = [p("a", 12.5), p("b", 33.25), p("c", 33.25), p("d", 5, true)];
|
||||||
|
const total = players.reduce((s, x) => s + x.contribution, 0);
|
||||||
|
const uncalled = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
|
||||||
|
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
|
||||||
|
assert.equal(Math.round((layered + uncalled) * 100), Math.round(total * 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nobody contributed means no pots, not an empty main pot", () => {
|
||||||
|
assert.deepEqual(PotCalculator.layers([p("a", 0), p("b", 0)]), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PotCalculator.uncalledReturns", () => {
|
||||||
|
test("an overbet nobody called goes back to whoever bet it", () => {
|
||||||
|
// b shoves 200, a calls only 50 and everyone else folds.
|
||||||
|
const returns = PotCalculator.uncalledReturns([p("a", 50), p("b", 200)]);
|
||||||
|
assert.deepEqual(returns, [{ id: "b", amount: 150 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a called bet leaves nothing to return", () => {
|
||||||
|
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 50), p("b", 50)]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the cap is the biggest live contribution, so a folder's overbet comes back", () => {
|
||||||
|
const returns = PotCalculator.uncalledReturns([p("live", 30), p("bully", 90, true)]);
|
||||||
|
assert.deepEqual(returns, [{ id: "bully", amount: 60 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nobody live it stays quiet rather than refunding the table", () => {
|
||||||
|
// Every fold flag set is a dealer mistake to correct, not a signal that
|
||||||
|
// every chip on the felt was uncalled.
|
||||||
|
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 10, true), p("b", 20, true)]), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PotCalculator.resolveRound", () => {
|
||||||
|
test("a single winner takes the layer they were matched for", () => {
|
||||||
|
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
|
||||||
|
const result = PotCalculator.resolveRound(entries, ["a"]);
|
||||||
|
assert.equal(result.payoutPool, 100);
|
||||||
|
assert.deepEqual(result.perWinner, [{ id: "a", amount: 100 }]);
|
||||||
|
assert.equal(result.remainingContenders.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an all-in winner only takes what everyone matched, the rest rolls forward", () => {
|
||||||
|
const entries = PotCalculator.buildEntries([p("short", 20), p("b", 60), p("c", 60)]);
|
||||||
|
const result = PotCalculator.resolveRound(entries, ["short"]);
|
||||||
|
assert.equal(result.capApplied, 20);
|
||||||
|
assert.equal(result.payoutPool, 60, "20 from each of three players");
|
||||||
|
// b and c still have 40 each left to play for.
|
||||||
|
assert.deepEqual(result.remainingContenders.map(e => e.id).sort(), ["b", "c"]);
|
||||||
|
assert.equal(PotCalculator.totalPot(result.updatedEntries), 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a chopped pot splits evenly", () => {
|
||||||
|
const entries = PotCalculator.buildEntries([p("a", 30), p("b", 30)]);
|
||||||
|
const result = PotCalculator.resolveRound(entries, ["a", "b"]);
|
||||||
|
assert.deepEqual(result.perWinner, [{ id: "a", amount: 30 }, { id: "b", amount: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("odd cents go to the winners in selection order and nothing is lost", () => {
|
||||||
|
// A $10.01 pot three ways doesn't divide: 3.34 / 3.34 / 3.33.
|
||||||
|
const entries = PotCalculator.buildEntries([p("a", 3.34), p("b", 3.34), p("c", 3.33)]);
|
||||||
|
const result = PotCalculator.resolveRound(entries, ["a", "b", "c"]);
|
||||||
|
const paid = result.perWinner.reduce((s, w) => s + w.amount, 0);
|
||||||
|
assert.equal(Math.round(paid * 100), Math.round(result.payoutPool * 100), "no chips lost to rounding");
|
||||||
|
assert.ok(result.perWinner[0].amount >= result.perWinner[2].amount, "odd cent goes to the first selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting nobody who can win is an error, not a silent no-op", () => {
|
||||||
|
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
|
||||||
|
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { SplitCalculator, Money } = require("./load");
|
||||||
|
|
||||||
|
describe("SplitCalculator.split", () => {
|
||||||
|
test("an even chop hands everyone the same stack", () => {
|
||||||
|
const result = SplitCalculator.split(90, 3, 1);
|
||||||
|
assert.equal(result.each, 30);
|
||||||
|
assert.equal(result.oddChips, 0);
|
||||||
|
assert.equal(result.dust, 0);
|
||||||
|
assert.equal(SplitCalculator.accountedFor(result), 90);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what won't divide is called out as odd chips, not rolled into a stack", () => {
|
||||||
|
// $100 three ways in $5 chips: 20 chips, 6 each, 2 left over.
|
||||||
|
const result = SplitCalculator.split(100, 3, 5);
|
||||||
|
assert.equal(result.each, 30);
|
||||||
|
assert.equal(result.oddChips, 2);
|
||||||
|
assert.equal(SplitCalculator.accountedFor(result), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pot that isn't a whole number of chips surfaces the remainder", () => {
|
||||||
|
// $47 in $5 chips: 9 chips ($45) and $2 that no chip in play can pay.
|
||||||
|
const result = SplitCalculator.split(47, 2, 5);
|
||||||
|
assert.equal(result.dust, 2, "the dealer picked a denomination the pot can't make");
|
||||||
|
assert.equal(SplitCalculator.accountedFor(result), 47);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every split accounts for the whole pot", () => {
|
||||||
|
const chips = [0.25, 0.5, 1, 5, 25, 100];
|
||||||
|
for (let pot = 1; pot <= 400; pot += 7) {
|
||||||
|
for (let ways = 2; ways <= 6; ways++) {
|
||||||
|
chips.forEach(chip => {
|
||||||
|
const result = SplitCalculator.split(pot, ways, chip);
|
||||||
|
assert.equal(
|
||||||
|
Money.toCents(SplitCalculator.accountedFor(result)),
|
||||||
|
Money.toCents(pot),
|
||||||
|
`${pot} split ${ways} ways in ${chip} chips lost money`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("odd chips are always fewer than the number of players", () => {
|
||||||
|
for (let pot = 1; pot <= 200; pot += 3) {
|
||||||
|
for (let ways = 2; ways <= 8; ways++) {
|
||||||
|
const result = SplitCalculator.split(pot, ways, 1);
|
||||||
|
assert.ok(result.oddChips < ways, "a whole extra chip each would have been dealt out");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty pot isn't a valid split", () => {
|
||||||
|
assert.equal(SplitCalculator.split(0, 3, 1).valid, false);
|
||||||
|
assert.equal(SplitCalculator.split(-5, 3, 1).valid, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nonsense inputs fall back to something a dealer can read", () => {
|
||||||
|
const result = SplitCalculator.split(50, 0, 0);
|
||||||
|
assert.equal(result.players, 1, "a split has to be at least one way");
|
||||||
|
assert.equal(result.chip, 0.01, "and at least one cent a chip");
|
||||||
|
assert.equal(SplitCalculator.accountedFor(result), 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { HoldemState, makeTable } = require("./load");
|
||||||
|
|
||||||
|
describe("HoldemState.advanceButton", () => {
|
||||||
|
test("the button moves to the next player, not the next empty chair", () => {
|
||||||
|
// Four players spread around a ten-seat table: 0, 3, 4, 8.
|
||||||
|
const state = makeTable({ seatCount: 10, seated: [0, 3, 4, 8], dealerSeatIndex: 0 });
|
||||||
|
HoldemState.advanceButton(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 3, "skips the three empty chairs between");
|
||||||
|
HoldemState.advanceButton(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 4);
|
||||||
|
HoldemState.advanceButton(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 8);
|
||||||
|
HoldemState.advanceButton(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 0, "wraps back round to the first player");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every player gets the button once per orbit", () => {
|
||||||
|
const seated = [1, 2, 6, 7, 9];
|
||||||
|
const state = makeTable({ seatCount: 12, seated, dealerSeatIndex: 1 });
|
||||||
|
const visited = [];
|
||||||
|
for (let i = 0; i < seated.length; i++) {
|
||||||
|
visited.push(state.dealerSeatIndex);
|
||||||
|
HoldemState.advanceButton(state);
|
||||||
|
}
|
||||||
|
assert.deepEqual(visited.slice().sort((a, b) => a - b), seated);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it still moves when nobody is dealt in, rather than looking stuck", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [], dealerSeatIndex: 2 });
|
||||||
|
HoldemState.advanceButton(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("counterclockwise play runs the button the other way", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 2, 4], dealerSeatIndex: 4 });
|
||||||
|
state.direction = "counterclockwise";
|
||||||
|
HoldemState.advanceButton(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HoldemState.getBlindSeats", () => {
|
||||||
|
test("blinds follow the players, not the chairs left of the button", () => {
|
||||||
|
// Seats 1 and 2 are empty, so the blinds belong on 3 and 7.
|
||||||
|
const state = makeTable({ seatCount: 9, seated: [0, 3, 7], dealerSeatIndex: 0 });
|
||||||
|
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(headsUp, false);
|
||||||
|
assert.equal(sbIndex, 3);
|
||||||
|
assert.equal(bbIndex, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heads-up, the button posts the small blind", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||||
|
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(headsUp, true);
|
||||||
|
assert.equal(sbIndex, 2, "the button is the small blind heads-up");
|
||||||
|
assert.equal(bbIndex, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heads-up with the button on an empty chair still posts both blinds", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [1, 4], dealerSeatIndex: 0 });
|
||||||
|
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(headsUp, true);
|
||||||
|
assert.equal(sbIndex, 1);
|
||||||
|
assert.equal(bbIndex, 4);
|
||||||
|
assert.notEqual(sbIndex, bbIndex, "two blinds never land on one player");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fewer than two players means no blinds to post", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [3], dealerSeatIndex: 0 });
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(sbIndex, null);
|
||||||
|
assert.equal(bbIndex, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HoldemState.sessionStandings", () => {
|
||||||
|
test("a player's cash position counts chips in front plus chips in the pot", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 0 });
|
||||||
|
const [a] = state.seats;
|
||||||
|
a.buyIns = 100;
|
||||||
|
a.stack = 60;
|
||||||
|
a.amountOverride = 25; // banked into the hand in progress
|
||||||
|
a.committed = 15; // still in front of them on this street
|
||||||
|
|
||||||
|
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 0 }]);
|
||||||
|
const standings = HoldemState.sessionStandings(state);
|
||||||
|
const row = standings.find(s => s.id === a.id);
|
||||||
|
|
||||||
|
// 60 in front + 40 in the pot - 100 bought in = level.
|
||||||
|
assert.equal(row.cashPosition, 0, "money in the pot is still theirs until it's won");
|
||||||
|
assert.equal(row.boughtIn, 100);
|
||||||
|
assert.equal(row.chipsNow, 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hand results accumulate across the session", () => {
|
||||||
|
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
|
||||||
|
const [a, b] = state.seats;
|
||||||
|
a.amountOverride = 10;
|
||||||
|
b.amountOverride = 10;
|
||||||
|
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 20 }]);
|
||||||
|
|
||||||
|
a.amountOverride = 5;
|
||||||
|
b.amountOverride = 5;
|
||||||
|
HoldemState.recordHand(state, [{ id: b.id, label: "P2", amount: 10 }]);
|
||||||
|
|
||||||
|
const standings = HoldemState.sessionStandings(state);
|
||||||
|
assert.equal(standings.find(s => s.id === a.id).net, 5);
|
||||||
|
assert.equal(standings.find(s => s.id === b.id).net, -5);
|
||||||
|
assert.equal(state.handNumber, 3, "the counter moved on twice");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the history is capped so a long session can still be saved", () => {
|
||||||
|
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
|
||||||
|
for (let i = 0; i < HoldemState.MAX_HISTORY_ENTRIES + 25; i++) {
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 1 }]);
|
||||||
|
}
|
||||||
|
assert.equal(state.handHistory.length, HoldemState.MAX_HISTORY_ENTRIES);
|
||||||
|
assert.equal(state.handHistory[0].handNumber, HoldemState.MAX_HISTORY_ENTRIES + 25, "newest first");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HoldemState.resizeSeats", () => {
|
||||||
|
test("the table is clamped to a size a dealer could actually run", () => {
|
||||||
|
const state = makeTable({ seatCount: 6 });
|
||||||
|
HoldemState.resizeSeats(state, 100);
|
||||||
|
assert.equal(state.seatCount, 24);
|
||||||
|
HoldemState.resizeSeats(state, 1);
|
||||||
|
assert.equal(state.seatCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unreadable seat count falls back to a standard table", () => {
|
||||||
|
// Empty or non-numeric input (and zero, which is equally meaningless)
|
||||||
|
// lands on the default rather than a two-seat table.
|
||||||
|
const state = makeTable({ seatCount: 6 });
|
||||||
|
HoldemState.resizeSeats(state, NaN);
|
||||||
|
assert.equal(state.seatCount, HoldemState.DEFAULT_SEAT_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shrinking past the button brings it back onto the table", () => {
|
||||||
|
const state = makeTable({ seatCount: 9, dealerSeatIndex: 8 });
|
||||||
|
HoldemState.resizeSeats(state, 4);
|
||||||
|
assert.ok(state.dealerSeatIndex < 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user