diff --git a/.github/workflows/holdem-tests.yml b/.github/workflows/holdem-tests.yml new file mode 100644 index 0000000..ab5eb91 --- /dev/null +++ b/.github/workflows/holdem-tests.yml @@ -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 diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/addChips.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/addChips.js index f63b3ee..1bcdf01 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/addChips.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/addChips.js @@ -35,7 +35,7 @@ const AddChips = { quickAmounts() { const base = this.baseAmount(); 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: "2 buy-ins", value: base * 2 }, ]; @@ -83,7 +83,7 @@ const AddChips = { confirm() { const amount = Math.max(0, Number(document.getElementById("addChipsInput").value) || 0); this.close(); - if (amount > 0) this.onConfirm(this.seatIndex, Math.round(amount * 100) / 100); + if (amount > 0) this.onConfirm(this.seatIndex, Money.round(amount)); }, }; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 234b85e..ff13cce 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -18,7 +18,7 @@ const Betting = { }, round(amount) { - return Math.round((Number(amount) || 0) * 100) / 100; + return Money.round(amount); }, // ---- Reading the table ---- @@ -87,10 +87,11 @@ const Betting = { 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) { - const count = state.seatCount; - const delta = state.direction === "counterclockwise" ? -step : step; - return ((fromIndex + delta) % count + count) % count; + return HoldemState.nextSeatIndex(state, fromIndex, step); }, // 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); }, - EPSILON_CHIPS: 0.004, + EPSILON_CHIPS: Money.EPSILON, canStillBet(state) { return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 30137cc..5c8a1c2 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -1,10 +1,23 @@ document.addEventListener("DOMContentLoaded", () => { 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 // way, so each one is undoable the same way the header buttons are. const seatAction = (index, message, mutate) => { - const snapshot = JSON.parse(JSON.stringify(state)); + const snapshot = snapshotState(); mutate(state.seats[index], index); HoldemState.save(state); Render.renderAll(); @@ -12,10 +25,7 @@ document.addEventListener("DOMContentLoaded", () => { Toast.show(message, { actionLabel: "Undo", onAction: () => { - Object.keys(state).forEach(key => delete state[key]); - Object.assign(state, snapshot); - HoldemState.save(state); - Render.renderAll(); + restoreState(snapshot); Toast.show("Undone.", { duration: 2500 }); }, duration: 6000, @@ -62,8 +72,8 @@ document.addEventListener("DOMContentLoaded", () => { seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => { // Chips in front and cash in are different questions — the stack moves // every hand, what they've handed over only moves when they buy. - player.stack = Math.round((Betting.stackOf(state, player) + amount) * 100) / 100; - player.buyIns = Math.round(((Number(player.buyIns) || 0) + amount) * 100) / 100; + player.stack = Money.round(Betting.stackOf(state, player) + amount); + player.buyIns = Money.round((Number(player.buyIns) || 0) + amount); }); }), }; @@ -104,7 +114,7 @@ document.addEventListener("DOMContentLoaded", () => { resolvePot: openPotWizard, beginAction: label => { - actionHistory.push({ label, snapshot: JSON.parse(JSON.stringify(state)) }); + actionHistory.push({ label, snapshot: snapshotState() }); if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift(); }, @@ -113,10 +123,7 @@ document.addEventListener("DOMContentLoaded", () => { undoLast: () => { const step = actionHistory.pop(); if (!step) return; - Object.keys(state).forEach(key => delete state[key]); - Object.assign(state, step.snapshot); - hooks.save(state); - Render.renderAll(); + restoreState(step.snapshot); 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 // undo is for the mistake you just made, not one from last night's session. const runUndoable = (message, mutate) => { - const snapshot = JSON.parse(JSON.stringify(state)); + const snapshot = snapshotState(); mutate(); // 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. @@ -174,10 +181,7 @@ document.addEventListener("DOMContentLoaded", () => { Toast.show(message, { actionLabel: "Undo", onAction: () => { - Object.keys(state).forEach(key => delete state[key]); - Object.assign(state, snapshot); - hooks.save(state); - Render.renderAll(); + restoreState(snapshot); Toast.show("Undone.", { duration: 2500 }); }, }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js index 455ca9f..912709e 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js @@ -1,7 +1,30 @@ -// Shared money formatting for every casino surface. Kept apart from the table's -// rendering so the quick-split calculator can pull in the formatter without dragging -// the whole seat/chip UI along with it. +// Shared money handling for every casino surface. Kept apart from the table's +// rendering so the quick-split calculator can pull it in without dragging the whole +// 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 = { + // 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) { const value = Number(amount) || 0; return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js index b2570c9..7894936 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js @@ -4,7 +4,7 @@ // A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount // that player put in the pot and is whittled down round by round as money gets paid out. const PotCalculator = { - EPSILON: 0.004, + 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 @@ -33,24 +33,39 @@ const PotCalculator = { // 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) { - 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 []; - 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 = []; let floor = 0; heights.forEach(height => { 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 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 - // called, and it goes back to whoever put it in. Announcing it as a side - // pot would overstate what's actually being played for. - if (amount > 0 && eligible.length > 0) { - layers.push({ amount: amount / 100, eligible }); + 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; }); @@ -66,7 +81,7 @@ const PotCalculator = { previous.eligible.every(id => layer.eligible.includes(id)); if (sameEligibility) { - previous.amount = Math.round((previous.amount + layer.amount) * 100) / 100; + previous.amount = Money.round(previous.amount + layer.amount); } else { merged.push({ ...layer }); } @@ -79,21 +94,28 @@ const PotCalculator = { }, // 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) { 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 // 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 (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 - .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); }, @@ -113,12 +135,12 @@ const PotCalculator = { if (e.remaining <= this.EPSILON) return e; const used = Math.min(e.remaining, cap); payoutPool += used; - return { ...e, remaining: Math.round((e.remaining - used) * 100) / 100 }; + return { ...e, remaining: Money.round(e.remaining - used) }; }); - payoutPool = Math.round(payoutPool * 100) / 100; - const baseShareCents = Math.floor((payoutPool * 100) / winners.length); - let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length; + 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)) @@ -133,7 +155,7 @@ const PotCalculator = { 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); return { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js index 2cf84f6..3ffbcef 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js @@ -240,7 +240,7 @@ const QuickSplit = { returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn")); 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() { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/splitCalculator.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/splitCalculator.js index 22252c2..3d2c259 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/splitCalculator.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/splitCalculator.js @@ -7,7 +7,7 @@ const SplitCalculator = { // Everything is done in cents to keep 0.1 + 0.2 out of a payout. toCents(amount) { - return Math.round((Number(amount) || 0) * 100); + return Money.toCents(amount); }, 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. accountedFor(result) { - return ( - Math.round(result.each * 100) * result.players + - Math.round(result.chip * 100) * result.oddChips + - Math.round(result.dust * 100) - ) / 100; + return Money.fromCents( + Money.toCents(result.each) * result.players + + Money.toCents(result.chip) * result.oddChips + + Money.toCents(result.dust) + ); }, }; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index f73b8ab..9ae4830 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -285,8 +285,8 @@ const HoldemState = { return Array.from(totals.values()) .map(t => { const seat = seatById.get(t.id); - const boughtIn = seat ? Math.round((Number(seat.buyIns) || 0) * 100) / 100 : 0; - const chipsNow = seat ? Math.round((Number(seat.stack) || 0) * 100) / 100 : 0; + const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0; + const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0; // Chips already pushed into the hand in progress are still this // 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 — @@ -296,12 +296,12 @@ const HoldemState = { return { ...t, - in: Math.round(t.in * 100) / 100, - won: Math.round(t.won * 100) / 100, - net: Math.round((t.won - t.in) * 100) / 100, + in: Money.round(t.in), + won: Money.round(t.won), + net: Money.round(t.won - t.in), boughtIn, chipsNow, - cashPosition: Math.round((chipsNow + inFrontOfThem - boughtIn) * 100) / 100, + cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn), }; }) .sort((a, b) => b.net - a.net); diff --git a/tests/holdem/README.md b/tests/holdem/README.md new file mode 100644 index 0000000..34582a9 --- /dev/null +++ b/tests/holdem/README.md @@ -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. diff --git a/tests/holdem/betting.test.js b/tests/holdem/betting.test.js new file mode 100644 index 0000000..7f7ed54 --- /dev/null +++ b/tests/holdem/betting.test.js @@ -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)); + }); +}); diff --git a/tests/holdem/invariants.test.js b/tests/holdem/invariants.test.js new file mode 100644 index 0000000..5e57e5e --- /dev/null +++ b/tests/holdem/invariants.test.js @@ -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`); + } + } + }); +}); diff --git a/tests/holdem/load.js b/tests/holdem/load.js new file mode 100644 index 0000000..24ad5d9 --- /dev/null +++ b/tests/holdem/load.js @@ -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