// The button when somebody gets up out of the blinds. // // Passing the button to the next *player* is right until a chair between the button // and the blinds empties. Then it pulls the whole blind order forward a place: the // player after the empty chair is never asked for a big blind, and the one after them // is asked an orbit early. The dead button rule holds the rotation to the seats for // one hand so the big blind still advances exactly one player. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const { HoldemState, Betting, makeTable } = require("./load"); // Four players in six chairs: Ann 0, Ben 1, Cal 2, Dee 3. const NAMES = ["Ann", "Ben", "Cal", "Dee"]; const table = (dealerSeatIndex = 0) => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex }); NAMES.forEach((name, i) => { state.seats[i].name = name; }); return state; }; const nameOf = (state, index) => index === null || index === undefined ? null : (state.seats[index].name || `empty seat ${index + 1}`); const blinds = state => { const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state); return { sb: nameOf(state, sbIndex), bb: nameOf(state, bbIndex) }; }; const leave = (state, index) => { state.seats[index].name = ""; state.seats[index].inHand = false; state.seats[index].stack = 0; state.seats[index].buyIns = 0; }; describe("somebody gets up out of the blinds", () => { test("the small blind leaves: dead button, and the big blind still moves one player", () => { const state = table(1); // button Ben, small Cal, big Dee Betting.beginHand(state); assert.deepEqual(blinds(state), { sb: "Cal", bb: "Dee" }); leave(state, 2); // Cal takes their chips and goes HoldemState.startNextHand(state); Betting.beginHand(state); assert.equal(state.dealerSeatIndex, 2, "the button holds Cal's empty chair"); assert.equal(state.seats[2].inHand, false, "which is the point — it's on nobody"); assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ann" }, "Dee moves from big to small and Ann posts the big blind, one player along"); }); test("and Ann is not skipped, which is the money in it", () => { // The old rotation passed the button to the next player, which made the blinds // Ann/Ben — Ann never posting a big blind at all, and Ben posting one an orbit // early. That is a real big blind out of a real player's stack. const state = table(1); Betting.beginHand(state); leave(state, 2); HoldemState.startNextHand(state); assert.equal(blinds(state).bb, "Ann", "Ann owes the big blind and still owes it"); }); test("the big blind leaves: no small blind is posted this hand", () => { const state = table(1); // button Ben, small Cal, big Dee Betting.beginHand(state); leave(state, 3); // Dee goes HoldemState.startNextHand(state); Betting.beginHand(state); const { sbIndex, bbIndex, deadSmallBlind } = HoldemState.getBlindSeats(state); assert.equal(state.dealerSeatIndex, 2, "the button reaches Cal as it was always going to"); assert.equal(sbIndex, null, "the small blind is dead"); assert.equal(deadSmallBlind, true, "and says so, rather than looking like a table with no blinds"); assert.equal(nameOf(state, bbIndex), "Ann", "the big blind still moves on one player"); }); test("a dead small blind still deals a hand, with one blind in the middle", () => { const state = table(1); Betting.beginHand(state); leave(state, 3); HoldemState.startNextHand(state); Betting.beginHand(state); assert.equal(state.bettingOpen, true, "the hand goes ahead"); const posted = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0); assert.equal(posted, state.bigBlind, "just the big blind, and nothing conjured to stand in for the small"); assert.equal(state.currentBet, state.bigBlind); assert.ok(state.actingSeatIndex !== null, "and somebody is on the clock"); }); test("somebody leaving from anywhere else changes nothing", () => { const state = table(1); // button Ben, small Cal, big Dee Betting.beginHand(state); leave(state, 0); // Ann, who was under the gun HoldemState.startNextHand(state); assert.equal(state.dealerSeatIndex, 2, "the button passes to Cal exactly as before"); assert.equal(state.deadButton, false); assert.equal(state.deadSmallBlind, false); assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ben" }); }); }); describe("the ordinary rotation is untouched", () => { test("a full table passes the button one player at a time", () => { const state = table(0); const seen = []; for (let hand = 0; hand < 8; hand++) { Betting.beginHand(state); seen.push(blinds(state).bb); HoldemState.startNextHand(state); } assert.deepEqual(seen, ["Cal", "Dee", "Ann", "Ben", "Cal", "Dee", "Ann", "Ben"], "two clean orbits, everybody paying one big blind per orbit"); assert.equal(state.deadButton, false); assert.equal(state.deadSmallBlind, false); }); test("players spread thinly around a big table don't create dead positions", () => { // Three players in nine chairs. The chairs between them were never in the // rotation, so holding to seats there would post blinds to nobody hand after // hand — which is why the button follows players, not chairs, by default. const state = makeTable({ seatCount: 9, seated: [0, 4, 7], stack: 200, dealerSeatIndex: 0 }); ["Ann", "", "", "", "Ben", "", "", "Cal"].forEach((n, i) => { state.seats[i].name = n; }); const seen = []; for (let hand = 0; hand < 6; hand++) { Betting.beginHand(state); const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state); assert.ok(state.seats[sbIndex].inHand, "a real player posts the small blind"); assert.ok(state.seats[bbIndex].inHand, "and a real player the big"); seen.push(nameOf(state, bbIndex)); HoldemState.startNextHand(state); } assert.deepEqual(seen, ["Cal", "Ann", "Ben", "Cal", "Ann", "Ben"], "one big blind each per orbit"); }); test("pressing next hand twice without dealing just passes the button along", () => { const state = table(0); Betting.beginHand(state); HoldemState.startNextHand(state); assert.equal(state.dealerSeatIndex, 1); HoldemState.startNextHand(state); assert.equal(state.dealerSeatIndex, 2, "and doesn't rotate off the same hand twice"); }); }); describe("the rule knows where it stops", () => { test("down to two players, heads-up takes over and nothing is left dead", () => { const state = table(1); Betting.beginHand(state); leave(state, 2); leave(state, 3); // both blinds walk at once 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"); assert.ok(state.seats[state.dealerSeatIndex].inHand, "and the button is on a player again"); }); test("a button stranded by accident is still rescued", () => { // Not the dead button: nobody left out of the blinds, the chair the button is // on just emptied. That one does want putting back on a player. const state = table(3); state.seats[3].inHand = false; HoldemState.ensureButtonOnPlayer(state); assert.ok(state.seats[state.dealerSeatIndex].inHand); }); test("moving the button by hand clears the dead positions", () => { const state = table(1); Betting.beginHand(state); leave(state, 2); HoldemState.startNextHand(state); assert.equal(state.deadButton, true); // What the seat menu's "Give the button" does. state.dealerSeatIndex = 0; state.deadButton = false; state.deadSmallBlind = false; Betting.beginHand(state); const { sbIndex } = HoldemState.getBlindSeats(state); assert.ok(state.seats[sbIndex].inHand, "a real small blind again, where the dealer put it"); }); test("naming a blind by hand overrules a dead one", () => { const state = table(1); Betting.beginHand(state); leave(state, 3); HoldemState.startNextHand(state); assert.equal(state.deadSmallBlind, true); state.blindOverride = { sbIndex: 1, bbIndex: 0 }; const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state); assert.equal(manual, true); assert.equal(sbIndex, 1, "the dealer's word beats the rule"); assert.equal(bbIndex, 0); }); test("a table saved before any of this reads as nothing dead", () => { const state = table(0); const parsed = JSON.parse(JSON.stringify(state)); delete parsed.deadButton; delete parsed.deadSmallBlind; delete parsed.postedBlinds; const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]); globalThis.localStorage = { getItem: key => (store.has(key) ? store.get(key) : null), setItem: (key, value) => store.set(key, value), removeItem: key => store.delete(key), }; const loaded = HoldemState.load(); assert.equal(loaded.deadButton, false); assert.equal(loaded.deadSmallBlind, false); assert.equal(loaded.postedBlinds, null); HoldemState.startNextHand(loaded); assert.ok(loaded.seats[loaded.dealerSeatIndex].inHand, "and rotates the ordinary way"); delete globalThis.localStorage; }); }); describe("chips are not invented or lost by any of it", () => { test("posting one blind instead of two moves chips and doesn't make them", () => { // Measured across the deal, which is the step that touches stacks. A hand with // a dead small blind has one blind in the middle instead of two — the danger is // a null small blind being treated as a zero-cost posting somewhere, or the big // blind quietly covering for it. const state = table(1); const chips = s => s.seats.reduce( (sum, p) => sum + Betting.stackOf(s, p) + Betting.handTotal(p), 0); Betting.beginHand(state); leave(state, 3); // the big blind goes, small blind dies for (let hand = 0; hand < 4; hand++) { HoldemState.startNextHand(state); const before = chips(state); Betting.beginHand(state); assert.equal(chips(state), before, `the deal drifted the count on hand ${hand + 1}`); } }); test("a dead small blind takes exactly one blind out of one stack", () => { const state = table(1); Betting.beginHand(state); leave(state, 3); HoldemState.startNextHand(state); const stacks = () => state.seats.map(p => Betting.stackOf(state, p)); const before = stacks(); Betting.beginHand(state); const after = stacks(); const paid = before.map((v, i) => v - after[i]).filter(d => d !== 0); assert.deepEqual(paid, [state.bigBlind], "one player, one big blind, nobody else touched"); }); });