Keep the big blind moving one player at a time when somebody leaves 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. Measured,
four-handed, button on Ben, blinds Cal and Dee, with Cal cashing out:
tool: button Dee, small blind Ann, big blind Ben
correct: dead button on Cal's chair, small blind Dee, big blind Ann
Ann is never asked for the big blind she owes, and Ben pays one a full orbit early.
That is a real big blind out of a real stack, and it happens every time somebody
stands up out of the blinds — which in a cash game is constantly. It is also the
kind of thing the dealer gets blamed for, because the tool is the thing everyone at
the table is reading position off.
The dead button rule holds the rotation to the seats for one hand instead of to the
players. The button follows the small blind round, which at a table with nobody
missing is the same next-player-along it always was; when that player has gone the
button rests on their empty chair for a hand, and when the big blind has gone no
small blind is posted. Either way the big blind advances exactly one player, which
is the whole point of it.
Worked out from who actually posted last hand rather than from the seating as it
stands, because by the time the question is asked the people it turns on are the
ones who have left. Heads-up is exempt — the button is the small blind there and
there is no position to leave dead — as is a table too sparse to have a rotation to
hold to, and a dealer who moves the button or names a blind by hand overrules it.
Both dead positions are said out loud rather than left to look like a fault: the
deal prompt explains them in a sentence, and the dead button goes hollow and dashed
so the felt agrees with the words.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`
|
||||
: "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user