From c77161c3e99feb9319d712b5c7fba911e00b607a Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 10:06:58 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 7 ++++ .../wwwroot/js/casino/holdem/actionBar.js | 11 +++++ .../wwwroot/js/casino/holdem/betting.js | 14 ++++++- .../wwwroot/js/casino/holdem/main.js | 22 ++++++---- .../wwwroot/js/casino/holdem/state.js | 22 +++++++--- tests/holdem/dealStep.test.js | 42 +++++++++++++++++++ 6 files changed, 104 insertions(+), 14 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 3e71d26..c592157 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -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; +} diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index ec2032e..2befb48 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -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", () => { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 764d406..734ba37 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -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), }; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 8173a22..00fd555 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -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); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index fdcf299..a3621ec 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -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 diff --git a/tests/holdem/dealStep.test.js b/tests/holdem/dealStep.test.js index 86874a1..9c73529 100644 --- a/tests/holdem/dealStep.test.js +++ b/tests/holdem/dealStep.test.js @@ -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); + }); +});