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
199 lines
8.4 KiB
JavaScript
199 lines
8.4 KiB
JavaScript
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));
|
|
});
|
|
});
|