Compare commits

..
Author SHA1 Message Date
jheapsandClaude Opus 5 cc060b29b8 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
2026-09-01 22:04:57 -06:00
jheapsandClaude Opus 5 3149b48c14 Remember the pots the dealer throws away, so the bank check stops blaming them
Starting the next hand with money still in the middle destroys it. The tool asks
first, and says afterwards how much went. Nothing wrote it down, though, so when
the bank check came round at cash-out it saw only that chips were gone:

    $5 more cash than chips in play. Someone has paid for chips they haven't
    been given.

and settle-up told the dealer to fix it before anyone cashed out. Both were
accusing the dealer of a miscount for money the tool itself had removed, at the
dealer's instruction, minutes earlier — the one kind of shortfall that isn't a
mistake, reported as the one kind that is.

Discards are now recorded on the table as they happen (undoably, with the rest
of the Next Hand step), the reconciliation counts them alongside the chips, and
the panel shows a "Taken off the table" line so the number is visible rather
than merely subtracted. A genuine shortfall on top of a discard still reports,
at its true size — the discard accounts for its own $5 and not a cent more.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 20:27:46 -06:00
jheapsandClaude Opus 5 cb057b5159 Stop the think-clock claiming things it can't know
The clock beside the acting player told two lies, in opposite directions.

Refresh mid-hand and it restarts at 0:00. The clock lives in memory, so a reload wipes
it, and the bar then says the player has just started thinking when they might have
been in the tank for two minutes. It is the one number in the bar whose whole job is to
tell a dealer when to nudge somebody, and a refresh reset it to "no need".

It cannot know that time -- the action moved before this session existed -- so it does
not guess. It shows a dash until the action next moves, and says why if you hover:

  "The action was already on this seat when the page loaded, so there's nothing to
   time from yet. The clock starts when the action next moves."

The moment the action moves it is measuring something it saw, and it counts from zero
again.

And past an hour it read "65:03". A table left open on a hand really does get there --
the state survives a refresh by design -- and sixty-five minutes written as though it
were minutes and seconds is a number the eye gets wrong. It reads 1:05:03 now.

Checked in the browser rather than reasoned about: dealt in this session it counts 0:00
then 0:02; refreshed mid-hand it shows the dash and keeps showing it rather than
drifting up from an invented zero; one action later it is back to a real 0:00; and with
the clock wound forward it reads 1:05:04 with the slow-tank styling still applied.

5 tests (283 total) on the formatting, including that minutes are padded once there are
hours and not before, and that a day reads 25:00:00 rather than wrapping.

Also checked and correct already: a backgrounded tab does not lose time -- four minutes
away reads 4:01 on return, because the clock is a timestamp difference rather than a
count of ticks -- and the timer disappears entirely once the hand is over.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 20:06:42 -06:00
10 changed files with 645 additions and 25 deletions
@@ -127,6 +127,10 @@
<span>Chips in play</span>
<span id="bankChipsInPlay"></span>
</div>
<div class="bankReconcileRow" id="bankDiscardedRow" hidden>
<span>Taken off the table</span>
<span id="bankDiscarded"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
</div>
@@ -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;
@@ -11,6 +11,11 @@ const ActionBar = {
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
// Whoever was already on the clock when this page loaded — see tankUnknownFor.
this.tankUnknownFor = state.bettingOpen &&
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
? state.actingSeatIndex
: null;
this.watchHeight();
this.render();
},
@@ -207,6 +212,14 @@ const ActionBar = {
tankStartedAt: null,
tankInterval: null,
// The seat that was already being asked to act when this page loaded, if there was
// one. Its clock is unknowable: the action moved to them before this session
// started — maybe two minutes ago, maybe last night — and starting from zero would
// have the bar say they had just begun thinking. Which it did, every time a dealer
// refreshed mid-hand. Cleared the moment the action moves, because from then on the
// clock is measuring something it actually saw.
tankUnknownFor: null,
// Restarts only when the action actually moves — a re-render for any other reason
// (a chip edit, a name change) must not put a thinking player back to zero.
syncTankTimer() {
@@ -220,6 +233,8 @@ const ActionBar = {
}
if (this.tankSeatIndex !== index) {
// Once the action has moved under our own eyes, nothing is unknown any more.
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
}
@@ -243,13 +258,33 @@ const ActionBar = {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—";
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
el.classList.remove("tankNudge", "tankSlow");
return;
}
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
const mins = Math.floor(seconds / 60);
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
el.title = "How long this player has been thinking";
el.textContent = this.formatTank(seconds);
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
},
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
// open on a hand does reach that, and sixty-five minutes written as though it were
// a minutes-and-seconds reading is a number the eye gets wrong.
formatTank(seconds) {
const pad = value => String(value).padStart(2, "0");
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = seconds % 60;
return hours > 0
? `${hours}:${pad(minutes)}:${pad(rest)}`
: `${minutes}:${pad(rest)}`;
},
// Shown only once someone is all-in and the money has actually split — a single
// undivided pot needs no announcement, and a line that's always there stops being
// read.
@@ -566,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
+24 -6
View File
@@ -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,17 +381,30 @@ 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.`
: "";
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
// Written down as well as announced. The toast is a moment; the bank check
// is asked at cash-out, and until now it saw only that the chips were gone
// and reported them as missing.
if (unresolved > Betting.EPSILON_CHIPS) {
state.discarded = Money.round((Number(state.discarded) || 0) + unresolved);
}
HoldemState.startNextHand(state);
});
});
@@ -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;
@@ -876,8 +885,13 @@ const Render = {
note.textContent =
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
} else {
note.textContent =
`${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
// If a pot was thrown away on purpose, that's where the gap went, and the
// dealer already answered a dialog about it. Telling them to go and fix it
// would be sending them after their own decision.
const discarded = Math.max(0, Number(this.state.discarded) || 0);
note.textContent = discarded > 0
? `${Money.format(plan.overBy)} is owed with nobody to pay it to. ${Money.format(discarded)} was taken off the table when a hand was abandoned, which accounts for ${discarded >= plan.overBy ? "it" : "some of it"}.`
: `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
}
note.classList.toggle("settleUpOff", !plan.balanced);
},
@@ -896,9 +910,22 @@ const Render = {
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
// An abandoned pot the dealer confirmed throwing away is money that left the
// table on purpose. It gets its own line so the two columns still add up in
// front of them, rather than looking like a shortfall they have to explain.
const discardedRow = document.getElementById("bankDiscardedRow");
if (discardedRow) {
discardedRow.hidden = !(bank.discarded > 0);
if (bank.discarded > 0) {
document.getElementById("bankDiscarded").textContent = Money.format(bank.discarded);
}
}
const verdict = document.getElementById("bankReconcileVerdict");
if (bank.balanced) {
verdict.textContent = "Balanced — the cash covers every chip on the table.";
verdict.textContent = bank.discarded > 0
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
+101 -3
View File
@@ -51,6 +51,9 @@ const HoldemState = {
notes: "",
handNumber: 1,
handHistory: [],
// Chips taken off the table on purpose, so the bank check can tell them
// apart from chips that have gone missing.
discarded: 0,
smallBlind: 1,
bigBlind: 2,
defaultStack: null,
@@ -78,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,
};
},
@@ -146,16 +159,23 @@ const HoldemState = {
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
// Tables saved before live betting existed are missing its fields; fill
// 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,
@@ -323,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);
@@ -361,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;
@@ -383,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) {
@@ -414,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;
@@ -590,10 +679,19 @@ const HoldemState = {
0
);
const difference = Money.round(chipsInPlay - cashIn);
// Chips the dealer deliberately took off the table — an abandoned pot they
// were asked about and confirmed. They are gone, so they can't be counted as
// in play, but they aren't missing either: the box knows exactly where they
// went. Without this the check blamed the tool's own confirmed action on
// "someone has paid for chips they haven't been given", which sends a dealer
// hunting for a miscount that never happened.
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
const difference = Money.round(chipsInPlay + discarded - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
+268
View File
@@ -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");
});
});
+94
View File
@@ -0,0 +1,94 @@
// Chips the dealer took off the table on purpose.
//
// Starting the next hand with money still in the middle destroys it — the tool asks
// first and says so afterwards. What it didn't do was remember, so the bank check saw
// only that chips were gone and reported them as missing.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState, Money, makeTable } = require("./load");
const table = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
state.seats.slice(0, 4).forEach(player => { player.buyIns = 200; });
return state;
};
test("a table with nothing thrown away balances as before", () => {
const bank = HoldemState.bankReconciliation(table());
assert.equal(bank.balanced, true);
assert.equal(bank.discarded, 0);
});
test("chips that went missing are still reported", () => {
// The check exists for this: cash in the box with no chips to match it.
const state = table();
state.seats[0].stack = 150;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -50);
});
test("an abandoned pot is accounted for rather than counted as missing", () => {
const state = table();
state.seats[1].stack = 199;
state.seats[2].stack = 198;
state.seats[3].stack = 198;
state.discarded = 5;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.chipsInPlay, 795);
assert.equal(bank.discarded, 5);
assert.equal(bank.balanced, true, "the box knows exactly where that $5 went");
});
test("throwing a pot away doesn't hide a real shortfall underneath it", () => {
const state = table();
state.seats[1].stack = 199;
state.seats[2].stack = 198;
state.seats[3].stack = 148; // $50 genuinely unaccounted for as well
state.discarded = 5;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -50);
});
test("a nonsense discarded value can't move the answer", () => {
const state = table();
state.discarded = -100;
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
state.discarded = "not a number";
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
});
test("a table saved before this existed reads as nothing thrown away", () => {
const saved = JSON.stringify(table());
const parsed = JSON.parse(saved);
delete parsed.discarded;
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.discarded, 0);
assert.equal(HoldemState.bankReconciliation(loaded).balanced, true);
delete globalThis.localStorage;
});
test("the amounts still add up the way the panel shows them", () => {
const state = table();
state.seats[1].stack = 199;
state.seats[2].stack = 198;
state.seats[3].stack = 198;
state.discarded = 5;
const bank = HoldemState.bankReconciliation(state);
assert.equal(Money.round(bank.chipsInPlay + bank.discarded), bank.cashIn);
});
+37
View File
@@ -0,0 +1,37 @@
// How the think-clock reads.
const test = require("node:test");
const assert = require("node:assert");
const path = require("path");
const ActionBar = require(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"));
test("seconds under a minute", () => {
assert.equal(ActionBar.formatTank(0), "0:00");
assert.equal(ActionBar.formatTank(7), "0:07");
assert.equal(ActionBar.formatTank(59), "0:59");
});
test("minutes and seconds", () => {
assert.equal(ActionBar.formatTank(60), "1:00");
assert.equal(ActionBar.formatTank(241), "4:01");
assert.equal(ActionBar.formatTank(3599), "59:59");
});
test("past the hour it says the hour", () => {
// A table left open on a hand reaches this, and "65:03" is a number the eye reads
// as minutes and seconds when it isn't.
assert.equal(ActionBar.formatTank(3600), "1:00:00");
assert.equal(ActionBar.formatTank(3903), "1:05:03");
assert.equal(ActionBar.formatTank(45296), "12:34:56");
});
test("the minutes are padded once there are hours, and not before", () => {
// "1:5:03" would be wrong; "05:03" on its own would be odd.
assert.equal(ActionBar.formatTank(3903), "1:05:03");
assert.equal(ActionBar.formatTank(303), "5:03");
});
test("a day is still a readable number rather than a wrapped one", () => {
assert.equal(ActionBar.formatTank(90000), "25:00:00");
});