Stop the last two things the table did behind the dealer's back

Your bug report was about a category, not one button, so I went looking for the
rest of the guessing rather than picking a new topic. Two left, both about the
button -- and one of them was in the fix I'd just shipped.

The deal preview lied

The prompt added last time promises who will post. With the button parked on an
empty chair it promised the wrong pair: it read the button where it currently sat,
while dealing relocates the button onto a player first and only then works out the
blinds.

  three players in seats 2-4, button parked on empty seat 1
    preview said:  s2 posts $1, s3 posts $2
    dealing gave:  s3 posts $1, s4 posts $2

Exactly the fault you reported -- the display and the thing it describes computed
from different moments -- inside the change meant to remove it. buttonSeatForDeal
answers where the button will be without moving it, so the preview asks the same
question the deal will answer. And the move is now stated before it happens:
"Button moves to Ben - seat 1 is empty."

Moving the button after the deal moved the blinds silently

Once the blinds are posted, moving the button takes them back and re-posts them,
because otherwise the badges and the money disagree. That's the right behaviour
and it was completely silent. The toast says so now: "Button on Dee - blinds taken
back and re-posted."

It also only does it when there is actually something posted to move. Before the
deal there is nothing committed, so passing the button is just moving a marker --
which is the whole point of the setup step.

Checked and deliberately left alone

Streets still advance one press at a time during an all-in run-out, which is right
-- the dealer is physically dealing the board between them. A player who joins
mid-hand is still marked folded for that hand and plays the next one. The
base-pot-amount shorthand still stands in for "everyone antes the same". Nothing
mutates state during a render.

4 tests (152 total): the preview matches what dealing produces with the button on
an empty chair, asking where the button will be doesn't move it, a button already
on a player is left alone, and an empty table doesn't break the question.

Driven through the UI: the prompt warns about the move and then the deal matches
it exactly; moving the button after the deal announces the re-post; and the blinds
survive being passed around three seats with the chip count fixed at 400. Layout
and contrast audits clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-01 10:06:58 -06:00
co-authored by Claude Opus 5
parent 843ef9cd42
commit c77161c3e9
6 changed files with 104 additions and 14 deletions
@@ -3006,3 +3006,10 @@ textarea:focus-visible {
color: #ecffef;
font-weight: 600;
}
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
would otherwise only discover afterwards. */
.dealPromptLine.dealPromptAside {
font-size: 0.72rem;
color: #9a9a9a;
}
@@ -494,6 +494,17 @@ const ActionBar = {
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
wrap.appendChild(line);
// 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.
if (preview.buttonMoves) {
const moved = document.createElement("p");
moved.className = "dealPromptLine dealPromptAside";
moved.textContent =
`Button moves to ${name(preview.buttonIndex)} — seat ${this.state.dealerSeatIndex + 1} is empty.`;
wrap.appendChild(moved);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
@@ -33,10 +33,22 @@ const Betting = {
// while the table is still being set, so a dealer can see where they'll land
// before any money moves.
blindPreview(state) {
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(state);
// Dealing relocates a button parked on an empty chair before it works out who
// posts, so the preview has to answer for the button where it *will* be. Asking
// from where it currently sits gave a different pair of blinds than the deal
// produced — the preview promising one thing and doing another is exactly the
// guessing this whole step exists to remove.
const buttonIndex = HoldemState.buttonSeatForDeal(state);
const asDealt = buttonIndex === state.dealerSeatIndex
? state
: { ...state, dealerSeatIndex: buttonIndex };
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
if (sbIndex === null || bbIndex === null) return null;
return {
sbIndex, bbIndex, headsUp, manual: !!manual,
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
};
+15 -7
View File
@@ -96,13 +96,21 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
// Before anyone has acted the blinds are still just sitting there, so
// moving the button moves them too — a home game passes it round several
// times before the cards come out, and the posted chips have to follow.
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
}),
makeDealer: index => {
// Before the deal there's nothing posted, so this is just moving a marker.
// After it, the blinds have to follow the button or the badges and the
// money disagree — but that's a real thing happening to real chips, and
// the dealer gets told rather than finding out later.
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
seatAction(index, willRepost
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
: `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
if (willRepost) Betting.repostBlinds(state);
});
},
assignBlind: (index, which) => {
const blinds = HoldemState.getBlindSeats(state);
@@ -273,15 +273,25 @@ const HoldemState = {
// position off it. On the very first hand of a fresh table it starts on seat 1
// whether or not anybody is sitting there, and a table that empties out around it
// leaves it stranded. Move it to a real player before a hand is dealt.
ensureButtonOnPlayer(state) {
if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return;
// Where the button will be when the hand is dealt — without moving it.
//
// Kept separate from ensureButtonOnPlayer because the deal preview has to be able
// to ask the question without answering it. It couldn't, and so the preview lied:
// with the button parked on an empty chair it promised one pair of blinds and
// dealing produced another, because beginHand relocates the button first and then
// works out who posts.
buttonSeatForDeal(state) {
const seated = state.seats[state.dealerSeatIndex];
if (seated && seated.inHand) return state.dealerSeatIndex;
for (let step = 0; step < state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) {
state.dealerSeatIndex = index;
return;
}
if (state.seats[index].inHand) return index;
}
return state.dealerSeatIndex;
},
ensureButtonOnPlayer(state) {
state.dealerSeatIndex = this.buttonSeatForDeal(state);
},
// A hand-picked blind is only usable if that seat is actually in the hand — a
+42
View File
@@ -161,3 +161,45 @@ describe("a table saved mid-hand comes back mid-hand", () => {
assert.equal(state.bettingOpen, false);
});
});
describe("the preview tells the truth about the button", () => {
test("a button parked on an empty chair is answered for where it will be", () => {
// The preview used to read the button where it sat, while dealing relocated it
// first — so it promised one pair of blinds and produced another.
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
state.bettingOpen = false;
assert.equal(state.seats[0].inHand, false, "the button is on an empty chair");
const preview = Betting.blindPreview(state);
assert.equal(preview.buttonMoves, true, "and the preview knows it will move");
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind);
assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind);
assert.equal(state.dealerSeatIndex, preview.buttonIndex, "and it moved where it said");
});
test("asking where the button will be doesn't move it", () => {
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
state.bettingOpen = false;
Betting.blindPreview(state);
Betting.blindPreview(state);
assert.equal(state.dealerSeatIndex, 0, "the preview is a question, not an action");
});
test("a button already on a player is left alone", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.bettingOpen = false;
const preview = Betting.blindPreview(state);
assert.equal(preview.buttonMoves, false);
assert.equal(preview.buttonIndex, 0);
});
test("buttonSeatForDeal copes with a table nobody is sitting at", () => {
const state = makeTable({ seatCount: 6, seated: [] });
assert.equal(HoldemState.buttonSeatForDeal(state), state.dealerSeatIndex);
});
});