// 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`); } } }); });