// Home games are messy. People walk up mid-hand, storm off mid-hand, argue about // whose blind it is, and pass the button round three times before anyone deals. // These are the cases the positional rules quietly get wrong. const { test, describe } = require("node:test"); const assert = require("node:assert/strict"); const { Betting, HoldemState, PotCalculator, Money, makeTable, chipsOnTable, contributions } = require("./load"); const potOf = state => HoldemState.totalPot(state); describe("a player leaves in the middle of a hand", () => { test("their chips stay in the pot", () => { const state = makeTable({ seatCount: 6, 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); Betting.sweepToHandTotals(state); const before = potOf(state); assert.equal(before, 8); state.seats[1].inHand = false; // dealer takes them out assert.equal(potOf(state), before, "the pot must not shrink when a player is taken out"); }); test("they cannot be paid out of a pot they walked away from", () => { const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 }); Betting.beginHand(state); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2); Betting.sweepToHandTotals(state); state.seats[1].inHand = false; const players = state.seats .filter(p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0) .map(p => ({ id: p.id, contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), folded: p.folded || HoldemState.isDeadMoney(p), })); const layers = PotCalculator.layers(players); assert.ok(!layers[0].eligible.includes(state.seats[1].id), "a departed player can't win"); assert.equal(layers.reduce((s, l) => s + l.amount, 0), 6, "but their money is still being played for"); }); test("taking out the player who is to act moves the action on", () => { const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); Betting.beginHand(state); const acting = state.actingSeatIndex; state.seats[acting].inHand = false; Betting.passAction(state, acting); assert.notEqual(state.actingSeatIndex, acting, "the table can't wait on somebody who left"); assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand); }); test("an empty chair still contributes nothing, base pot or not", () => { // The table-wide default must not be read as an ante from seats nobody is in. const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0 }); state.basePotAmount = 25; assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, 25), 0); }); }); describe("a player joins in the middle of a hand", () => { test("they sit the hand out rather than joining a pot they never paid into", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); Betting.beginHand(state); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2); Betting.sweepToHandTotals(state); // Walks up and is dealt in — the app marks them folded for this hand. state.seats[4].inHand = true; state.seats[4].stack = 100; state.seats[4].folded = true; assert.ok(!Betting.liveSeats(state).some(s => s.index === 4), "not live this hand"); assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, state.basePotAmount), 0); }); test("they are a full player again on the next hand", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); Betting.beginHand(state); state.seats[4].inHand = true; state.seats[4].stack = 100; state.seats[4].folded = true; HoldemState.startNextHand(state); Betting.beginHand(state); assert.equal(state.seats[4].folded, false); assert.ok(Betting.liveSeats(state).some(s => s.index === 4), "dealt in like anyone else"); }); }); describe("passing the button around before the cards come out", () => { test("moving the button re-posts the blinds and keeps the chip count straight", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); const startingChips = chipsOnTable(state); Betting.beginHand(state); assert.equal(state.seats[1].committed, 1); assert.equal(state.seats[2].committed, 2); state.dealerSeatIndex = 2; Betting.repostBlinds(state); assert.equal(state.seats[1].committed, 0, "the old small blind got their chip back"); assert.equal(state.seats[3].committed, 1, "and the new one has posted"); assert.equal(state.seats[0].committed, 2); assert.equal(chipsOnTable(state), startingChips, "no chips created or lost in the shuffle"); }); test("passing it round many times still balances", () => { const state = makeTable({ seatCount: 8, seated: [0, 2, 3, 5, 7], dealerSeatIndex: 0 }); const startingChips = chipsOnTable(state); Betting.beginHand(state); [2, 5, 7, 3, 0, 5].forEach(seat => { state.dealerSeatIndex = seat; Betting.repostBlinds(state); assert.equal(chipsOnTable(state), startingChips, `chips drifted after passing to seat ${seat}`); }); const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state); assert.equal(Betting.committed(state.seats[sbIndex]), 1); assert.equal(Betting.committed(state.seats[bbIndex]), 2); }); test("once somebody has acted, the blinds are settled", () => { const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); Betting.beginHand(state); assert.equal(Betting.onlyBlindsIn(state), true, "nothing but blinds so far"); Betting.call(state, 3); assert.equal(Betting.onlyBlindsIn(state), false, "a call means the blinds are set where they are"); }); }); describe("naming the blinds by hand", () => { test("a named blind wins over the button order", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); state.blindOverride = { sbIndex: 3, bbIndex: 0 }; const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state); assert.equal(sbIndex, 3); assert.equal(bbIndex, 0); assert.equal(manual, true); Betting.beginHand(state); assert.equal(state.seats[3].committed, 1); assert.equal(state.seats[0].committed, 2); assert.equal(state.seats[1].committed, 0, "the positional small blind posts nothing"); }); test("a named blind on a player who isn't dealt in is ignored", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); state.blindOverride = { sbIndex: 4, bbIndex: 0 }; // seat 4 is an empty chair const { sbIndex, manual } = HoldemState.getBlindSeats(state); assert.ok(!manual, "falls back to the button order rather than posting to nobody"); assert.ok(state.seats[sbIndex].inHand); }); test("both blinds on one player is refused", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); state.blindOverride = { sbIndex: 1, bbIndex: 1 }; const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state); assert.notEqual(sbIndex, bbIndex); }); test("naming blinds heads-up still acts in the right order", () => { const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 }); state.blindOverride = { sbIndex: 5, bbIndex: 2 }; // deliberately the reverse Betting.beginHand(state); assert.equal(state.seats[5].committed, 1); assert.equal(state.seats[2].committed, 2); assert.equal(state.actingSeatIndex, 5, "the small blind acts first pre-flop heads-up"); Betting.call(state, 5); Betting.check(state, 2); Betting.closeStreet(state); assert.equal(state.actingSeatIndex, 2, "and the big blind leads after the flop"); }); test("the naming only lasts the hand it was made for", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); state.blindOverride = { sbIndex: 3, bbIndex: 0 }; Betting.beginHand(state); HoldemState.startNextHand(state); assert.equal(state.blindOverride, null, "blinds go back to following the button"); }); }); describe("the first hand on a fresh table", () => { test("the button never starts on an empty chair", () => { const state = HoldemState.createDefault(); HoldemState.resizeSeats(state, 9); [4, 6, 7].forEach(i => { state.seats[i].inHand = true; state.seats[i].stack = 100; }); assert.equal(state.dealerSeatIndex, 0, "a fresh table starts the button on seat 1"); assert.equal(state.seats[0].inHand, false, "which nobody is sitting in"); Betting.beginHand(state); assert.ok(state.seats[state.dealerSeatIndex].inHand, "the button moved to a real player"); const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state); assert.ok(state.seats[sbIndex].inHand); assert.ok(state.seats[bbIndex].inHand); assert.notEqual(sbIndex, bbIndex); }); test("a table that empties out around the button recovers", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 3 }); state.seats[3].inHand = false; // the player holding the button leaves HoldemState.ensureButtonOnPlayer(state); assert.ok(state.seats[state.dealerSeatIndex].inHand); }); test("two players left after everyone else walks is heads-up, blinds reversed", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); [2, 3].forEach(i => { state.seats[i].inHand = false; }); HoldemState.startNextHand(state); Betting.beginHand(state); const { sbIndex, headsUp } = HoldemState.getBlindSeats(state); assert.equal(headsUp, true); assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind"); }); }); describe("chips survive a messy table", () => { test("joining, leaving and passing the button never changes the chip count", () => { const state = makeTable({ seatCount: 8, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); state.seats[6].stack = 100; // sitting down but not dealt in yet const startingChips = chipsOnTable(state); Betting.beginHand(state); state.dealerSeatIndex = 2; Betting.repostBlinds(state); state.seats[6].inHand = true; // walks up mid-hand state.seats[6].folded = true; assert.equal(chipsOnTable(state), startingChips); Betting.call(state, state.actingSeatIndex); state.seats[1].inHand = false; // and someone storms off Betting.sweepToHandTotals(state); assert.equal(chipsOnTable(state), startingChips, "every chip is still accounted for"); assert.equal( Money.toCents(potOf(state) + state.seats.reduce((s, p) => s + (Number(p.stack) || 0), 0)), Money.toCents(startingChips), "and the pot plus the stacks is the whole table" ); }); });