diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index cbb7273..2f4a3da 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -730,6 +730,18 @@ button, display: none; } +/* The dead button. It's on an empty chair on purpose, and that reads as a mistake — + a gold puck on nobody looks exactly like a button that has drifted and wants + dragging back. So it keeps the button's gold, to stay findable as the button, but + goes hollow and dashed: the same shape, visibly holding a place rather than + marking a player. The deal prompt says why in words; this is so the felt agrees + with it at a glance instead of contradicting it. */ +.dealerChip.active.dealerChipDead { + background: rgba(245, 213, 71, 0.14); + border-style: dashed; + color: #f0d98c; +} + .blindBadge { display: none; position: absolute; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 0e5d969..fbf55a4 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -601,12 +601,27 @@ const ActionBar = { return wrap; } - line.textContent = - `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` + - `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` + - `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`; + // A dead small blind is a hand with only one blind in it. Saying "nobody posts + // the small blind" out loud is the whole point — a dealer who reads a preview + // with a blind missing and isn't told why will assume the tool has lost track. + line.textContent = preview.deadSmallBlind + ? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` + : `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` + + `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` + + `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`; wrap.appendChild(line); + // Why a position is missing. Both of these are the dead button rule doing its + // job, and both look like a bug until somebody says otherwise. + if (preview.deadButton || preview.deadSmallBlind) { + const dead = document.createElement("p"); + dead.className = "dealPromptLine dealPromptAside"; + dead.textContent = preview.deadButton + ? `Dead button — seat ${preview.buttonIndex + 1} is empty. Its player left out of the blinds, so the button waits a hand rather than moving the big blind on twice.` + : `Dead small blind — the player due to post it left the table. The big blind still moves on one player, so nobody pays two in an orbit.`; + wrap.appendChild(dead); + } + // Dealing relocates a button sitting on an empty chair. That has to be said // before it happens rather than discovered afterwards — a fresh table starts // the button on seat 1 whether or not anyone is sitting there. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 0bf6856..bbc03c4 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -78,10 +78,11 @@ const Betting = { : { ...dealt, dealerSeatIndex: buttonIndex }; const satOut = this.brokeSeats(state); - const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt); - if (sbIndex === null || bbIndex === null) return null; + const { sbIndex, bbIndex, headsUp, manual, deadSmallBlind } = HoldemState.getBlindSeats(asDealt); + if (bbIndex === null) return null; return { - sbIndex, bbIndex, headsUp, manual: !!manual, + sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind, + deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand, buttonIndex, buttonMoves: buttonIndex !== state.dealerSeatIndex, satOut, @@ -448,16 +449,27 @@ const Betting = { const bb = Number(state.bigBlind) || 0; const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state); - if (sbIndex === null || bbIndex === null) { + // A null big blind means there aren't enough players to post at all. A null + // small blind with a big blind behind it is the dead small blind, which is a + // hand that gets dealt like any other — it just has one blind in it. + if (bbIndex === null) { state.currentBet = 0; state.actingSeatIndex = null; + state.postedBlinds = null; return; } - if (sb > 0) this.putIn(state, sbIndex, sb); + if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb); if (bb > 0) this.putIn(state, bbIndex, bb); - state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex]))); + // What the next hand's rotation is worked out from. Recorded here, where the + // blinds are actually posted, because by the time it's needed the seats have + // moved on and the answer can no longer be read off the table. + state.postedBlinds = { sbIndex, bbIndex }; + + state.currentBet = this.round(Math.max( + sbIndex === null ? 0 : this.committed(state.seats[sbIndex]), + this.committed(state.seats[bbIndex]))); state.lastRaiseSize = bb || 0; // Heads-up the button is the small blind and acts first before the flop; with diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 13c732a..95dd018 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -121,6 +121,11 @@ document.addEventListener("DOMContentLoaded", () => { ? `Button on ${nameOf(index)} — blinds taken back and re-posted.` : `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; + // Putting the button somewhere by hand is the dealer overruling the + // rotation, dead positions included. Leaving them set would hold a + // small blind dead at a table whose button has moved out from under it. + state.deadButton = false; + state.deadSmallBlind = false; if (willRepost) Betting.repostBlinds(state); }); }, @@ -376,12 +381,19 @@ document.addEventListener("DOMContentLoaded", () => { if (!ok) return; } - // startNextHand moves the button on by one; say where it landed so the - // dealer can confirm the rotation without hunting for the badge. - const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1); - const where = state.dedicatedDealer - ? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}` - : `button on seat ${nextDealer + 1}`; + // Say where the button landed so the dealer can confirm the rotation without + // hunting for the badge. Asked of the rotation itself rather than guessed at as + // "the next chair along" — which was only ever right at a table with nobody + // missing, and is the exact thing the dead button makes untrue. + const rotation = HoldemState.nextButtonRotation(state); + const nextDealer = rotation.buttonIndex !== null + ? rotation.buttonIndex + : HoldemState.buttonSeatForDeal({ ...state, dealerSeatIndex: HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1) }); + const where = rotation.deadButton + ? `dead button on seat ${nextDealer + 1}` + : state.dedicatedDealer + ? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}` + : `button on seat ${nextDealer + 1}`; const discarded = unresolved > Betting.EPSILON_CHIPS ? ` ${Money.format(unresolved)} taken off the table.` : ""; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 0f03a67..b413975 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -173,6 +173,12 @@ const Render = { layer.innerHTML = ""; const template = document.getElementById("seatTemplate"); const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state); + // A button on an empty chair normally means the table has drifted and wants + // fixing. Once it's the dead button it means the opposite — it's exactly where + // it belongs — so it gets marked rather than looking like the thing to correct. + const deadButtonHere = this.state.deadButton && + HoldemState.deadPositionsApply(this.state) && + !this.state.seats[dealerIndex].inHand; this.state.seats.forEach((player, index) => { const node = template.content.firstElementChild.cloneNode(true); @@ -201,8 +207,11 @@ const Render = { // of nine identical buttons and couldn't tell which seat held the real one. // The label now says whose it is, or what pressing it would do. const who = (player.name || "").trim() || `seat ${index + 1}`; + dealerChip.classList.toggle("dealerChipDead", hasButton && deadButtonHere); const chipLabel = hasButton - ? `${who} has the dealer button` + ? (deadButtonHere + ? `Dead button on empty seat ${index + 1} — its player left out of the blinds` + : `${who} has the dealer button`) : `Give the dealer button to ${who}`; dealerChip.setAttribute("aria-label", chipLabel); dealerChip.title = chipLabel; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 72df7cd..b63041c 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -81,6 +81,16 @@ const HoldemState = { // button order is the wrong answer often enough that it has to be // overridable. Cleared at the start of every hand. blindOverride: null, + // Who actually posted, the last time cards went out — { sbIndex, bbIndex }. + // The next hand's rotation is worked out from this rather than from the + // seating as it stands, because by then the people it needs to know about + // are exactly the ones who have got up and gone. + postedBlinds: null, + // Set when somebody leaves out of the blinds and the rotation has to hold + // to the seats to keep the big blind moving one player at a time. The + // button then sits on the chair they vacated, and no small blind is posted. + deadButton: false, + deadSmallBlind: false, }; }, @@ -155,11 +165,17 @@ const HoldemState = { // them in rather than making the dealer reset a table mid-session. const defaults = this.createDefault(); ["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", - "actingSeatIndex", "blindOverride"] + "actingSeatIndex", "blindOverride", "postedBlinds"] .forEach(key => { if (parsed[key] === undefined) parsed[key] = defaults[key]; }); + // A table saved before the dead button existed has neither flag. False is + // the right guess for both: it means the rotation carries on the ordinary + // way, which is what that table was already doing. + parsed.deadButton = parsed.deadButton === true; + parsed.deadSmallBlind = parsed.deadSmallBlind === true; + // A table saved before dealing and betting were separate has no flag, and // guessing false would show "deal the hand" over a hand already in // progress. Anything already committed, or anyone already on the clock, @@ -327,6 +343,10 @@ const HoldemState = { // sits on nobody most hands — and that isn't only untidy: position and blinds are // supposed to come round to each player at the same rate, and they don't if the // button is rotating past chairs nobody is sitting in. + // + // The fallback, now. nextButtonRotation is what a hand that was actually dealt + // rotates by; this is for the cases it has nothing to work from — a fresh table, + // a hand never dealt, a dealer pressing Next Hand twice to pass the button on. advanceButton(state) { for (let step = 1; step <= state.seatCount; step++) { const index = this.nextSeatIndex(state, state.dealerSeatIndex, step); @@ -365,6 +385,10 @@ const HoldemState = { buttonSeatForDeal(state) { const seated = state.seats[state.dealerSeatIndex]; if (seated && seated.inHand) return state.dealerSeatIndex; + // A button left on an empty chair on purpose is the dead button, and moving it + // to a player is precisely what it's there to stop. Only a button that has been + // stranded by accident gets rescued. + if (state.deadButton && this.deadPositionsApply(state)) return state.dealerSeatIndex; for (let step = 0; step < state.seatCount; step++) { const index = this.nextSeatIndex(state, state.dealerSeatIndex, step); if (state.seats[index].inHand) return index; @@ -387,6 +411,13 @@ const HoldemState = { return o; }, + // A dead button and a dead small blind are three-handed-and-up ideas. Down to two + // players the button *is* the small blind and the blinds simply alternate, so + // whatever the last rotation left behind stops applying. + deadPositionsApply(state) { + return state.seats.filter(p => p.inHand).length >= 3; + }, + // Who posts what. Heads-up is its own rule: with two players the button posts the // small blind, which is the opposite of the usual order. getBlindSeats(state) { @@ -418,11 +449,65 @@ const HoldemState = { return { dealerIndex, sbIndex, bbIndex, headsUp: true }; } + // The small blind is dead: whoever would have moved into it has left the table, + // and the seat stays empty for a hand rather than dragging the big blind + // forward an extra place. One player gets a free hand out of position for it; + // that's the price of nobody being charged two big blinds in an orbit. + if (state.deadSmallBlind && this.deadPositionsApply(state)) { + return { dealerIndex, sbIndex: null, bbIndex: live[0], headsUp: false, deadSmallBlind: true }; + } + return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false }; }, + // Where the button goes for the next hand, and whether anything is left dead. + // + // Ordinarily it follows the small blind round — the next player along, which is + // what it has always done. The case that needs care is somebody getting up out of + // the blinds. Passing the button to the next *player* then pulls the whole blind + // order forward a place: the player after the empty chair is never asked for a big + // blind at all, and the one after them is asked an orbit early. Holding the + // rotation to the seats instead is the dead button, and getting it wrong costs a + // real player a real big blind. + // + // Worked out from who actually posted last hand, not from the seating as it stands, + // because by now the people it needs to know about are the ones who have gone. + nextButtonRotation(state) { + const posted = state.postedBlinds; + const inRange = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i]; + const here = i => inRange(i) && state.seats[i].inHand; + + // Nothing to hold to: a fresh table, a hand that was never dealt, or one where + // the blinds were named by hand. And heads-up has its own rule — with two + // players the button is the small blind, so there is no position to leave dead. + if (!posted || !inRange(posted.sbIndex) || !inRange(posted.bbIndex) || !this.deadPositionsApply(state)) { + return { buttonIndex: null, deadButton: false, deadSmallBlind: false }; + } + + // Both still sitting there is the ordinary case, and this comes out as the next + // player along — the same answer as passing the button, arrived at from the + // blinds so that the unusual case doesn't need a separate path. + return { + buttonIndex: posted.sbIndex, + deadButton: !here(posted.sbIndex), + deadSmallBlind: !here(posted.bbIndex), + }; + }, + startNextHand(state) { - this.advanceButton(state); + const rotation = this.nextButtonRotation(state); + if (rotation.buttonIndex === null) { + this.advanceButton(state); + state.deadButton = false; + state.deadSmallBlind = false; + } else { + state.dealerSeatIndex = rotation.buttonIndex; + state.deadButton = rotation.deadButton; + state.deadSmallBlind = rotation.deadSmallBlind; + } + // Spent. Pressing Next Hand again without dealing is a dealer passing the + // button along, not a second rotation off the same hand. + state.postedBlinds = null; // Cards aren't out and nothing is posted until the dealer says so. state.bettingOpen = false; state.actingSeatIndex = null; diff --git a/tests/holdem/deadButton.test.js b/tests/holdem/deadButton.test.js new file mode 100644 index 0000000..4db4157 --- /dev/null +++ b/tests/holdem/deadButton.test.js @@ -0,0 +1,268 @@ +// 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"); + }); +});