From 843ef9cd4284dedf7a25e4803d4dbf6b81ad1815 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 09:51:43 -0600 Subject: [PATCH 1/4] Wait to be told to deal, instead of guessing Dealing and betting were one button. "Next Hand" moved the button and posted the blinds in the same breath, so the moment between hands -- the one where people are still sitting down, standing up and arguing about whose turn it is -- was spent with money already committed. That's where the guessing came from. The blind badges recompute from `inHand` every render, but the posted chips are frozen from whenever beginHand ran, so the two drift apart the instant the table changes: seat a fourth player after Next Hand badges said sb=s3 bb=s4, money on the table was s1=$2 s3=$1 s4 was shown as the big blind having posted nothing take out the big blind after Next Hand badges moved to bb=s1, s4's $2 stayed in the pot, s1 had posted nothing press Next Hand twice burned a hand, moved the button twice, chips went 300 -> 297 Now the dealer says when. Next Hand advances the button and clears the table, and stops. The bar shows who will post -- "Ben posts $1, Cal posts $2" -- and waits on "Post blinds & deal". Enter does it too, so a dealer can carry a hand from setup through the streets to showdown without reaching for the trackpad. Until that press nothing is committed, so setting the table is free: seat someone, take someone out, pass the button around, name the blinds by hand. The preview follows every change and the chip count never moves. Settling a pot ends the hand rather than starting the next one, for the same reason -- people cash out between hands. The preview is honest about what it can't do: fewer than two players and there's no button at all, just what's missing. A table saved before this existed has no flag, and guessing "not dealt" would offer to deal over a live hand. Anything already committed, or anyone already on the clock, means the betting was open when the tab was closed. 11 tests (148 total): the table waits, no money moves until the deal, seating and unseating and passing the button all cost nothing, what the preview promises is what gets posted, and a mid-hand save comes back mid-hand. Driven through the real UI: seat a player and move the button while waiting, watch the preview follow with the chip count fixed at 400, deal, play it out, settle, and land back in setup. Triple-tapping Next Hand now costs nothing but button position -- it used to cost a hand and three chips. Layout and contrast audits clean in both the new setup state and after dealing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 29 ++++ .../wwwroot/js/casino/holdem/actionBar.js | 50 +++++- .../wwwroot/js/casino/holdem/betting.js | 18 ++ .../wwwroot/js/casino/holdem/main.js | 11 +- .../wwwroot/js/casino/holdem/state.js | 31 ++++ tests/holdem/dealStep.test.js | 163 ++++++++++++++++++ 6 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 tests/holdem/dealStep.test.js diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index b413bb0..3e71d26 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2977,3 +2977,32 @@ textarea:focus-visible { display: none; } } + +/* ---------- The deal step ---------- + Between hands the bar shows who will post and waits. Dealing used to happen the + instant the previous hand ended, which committed blinds while people were still + sitting down — and left the blind badges disagreeing with the money already in. */ +.dealPrompt { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.dealPromptLine { + margin: 0; + text-align: center; + font-size: 0.8rem; + color: #c8c8c8; + line-height: 1.35; +} + +.dealPromptLine.dealPromptBlocked { + color: #9a9a9a; +} + +.actionBtn.actionDeal { + background: linear-gradient(180deg, #16783a, #0d5225); + border-color: #1e8449; + color: #ecffef; + font-weight: 600; +} diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 044c895..ec2032e 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -40,7 +40,9 @@ const ActionBar = { publish(); }, - // Anything to act on? A hand with nobody dealt in doesn't get a bar. + // Anything to act on? A hand with nobody dealt in doesn't get a bar — and that + // still holds during setup, because a seated player is a live seat whether or not + // the betting has been opened. An empty table stays uncluttered. isRunning() { return Betting.liveSeats(this.state).length > 0; }, @@ -66,7 +68,9 @@ const ActionBar = { const breakdown = this.renderPotBreakdown(); if (breakdown) host.appendChild(breakdown); - if (Betting.isHandOver(this.state)) { + if (!this.state.bettingOpen) { + host.appendChild(this.renderDealPrompt()); + } else if (Betting.isHandOver(this.state)) { host.appendChild(this.renderHandOver()); } else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) { host.appendChild(this.renderStreetDone()); @@ -458,6 +462,48 @@ const ActionBar = { note.hidden = true; }, + // The table is set but the cards aren't out. This is the step that used to happen + // by itself the instant the previous hand ended, which meant blinds were committed + // while people were still sitting down — and the blind badges would drift away from + // the money already posted as seats changed. Now the dealer says when. + renderDealPrompt() { + const wrap = document.createElement("div"); + wrap.className = "dealPrompt"; + + const preview = Betting.blindPreview(this.state); + const seated = Betting.liveSeats(this.state).length; + + const line = document.createElement("p"); + line.className = "dealPromptLine"; + + if (!preview) { + // Two players is the least you can deal. Say what's missing rather than + // offering a button that would quietly do nothing. + line.textContent = seated === 0 + ? "Nobody is dealt in yet. Tap a seat to add a player." + : "Only one player is dealt in — a hand needs two."; + line.classList.add("dealPromptBlocked"); + wrap.appendChild(line); + return wrap; + } + + const name = i => HoldemState.displayName(this.state.seats[i], i); + 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" : ""}`; + wrap.appendChild(line); + + const row = document.createElement("div"); + row.className = "actionButtons"; + row.appendChild(this.button("Post blinds & deal", "actionDeal", () => { + this.commit("dealing the hand", () => Betting.beginHand(this.state)); + })); + wrap.appendChild(row); + + return wrap; + }, + renderStreetDone() { const row = document.createElement("div"); row.className = "actionButtons"; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 29f293f..764d406 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -29,6 +29,19 @@ const Betting = { .filter(s => s.player.inHand && !s.player.folded); }, + // Who would post if the hand were dealt right now. Used to preview the blinds + // 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); + if (sbIndex === null || bbIndex === null) return null; + return { + sbIndex, bbIndex, headsUp, manual: !!manual, + smallBlind: this.round(state.smallBlind || 0), + bigBlind: this.round(state.bigBlind || 0), + }; + }, + committed(player) { return this.round(player.committed || 0); }, @@ -291,7 +304,12 @@ const Betting = { // Deals a fresh betting round: blinds posted, action to the seat after the big // blind, which is where a dealer expects to be looking when the cards go out. + // + // Called when the dealer says the table is ready, not automatically when the + // previous hand ends — everything before this point is setup, where seats and the + // button can move freely because no money has been committed. beginHand(state) { + state.bettingOpen = true; state.street = "preflop"; state.currentBet = 0; state.lastRaiseSize = 0; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 31be248..8173a22 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -159,8 +159,10 @@ document.addEventListener("DOMContentLoaded", () => { // hand's stacks are already right. Betting.creditWinnings(state, payouts); } + // Settling a pot ends the hand; it doesn't start the next one. The + // table goes back to setup so people can sit down or cash out before + // any blinds are committed. HoldemState.startNextHand(state); - Betting.beginHand(state); }); }); }; @@ -222,7 +224,9 @@ document.addEventListener("DOMContentLoaded", () => { return true; }, // Enter confirms a raise if one is being entered, otherwise moves the hand on. - advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance"), + // Dealing is an advance too — Enter should carry a dealer through setup, deal, + // streets and showdown without reaching for the trackpad. + advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"), undo: () => pressInBar(".actionUndo"), cancel: () => pressInBar(".raiseCancel"), }); @@ -345,9 +349,8 @@ document.addEventListener("DOMContentLoaded", () => { ? ` ${Money.format(unresolved)} taken off the table.` : ""; - runUndoable(`Next hand — ${where}. Blinds posted.${discarded}`, () => { + runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => { HoldemState.startNextHand(state); - Betting.beginHand(state); }); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 1cd4163..fdcf299 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -58,6 +58,20 @@ const HoldemState = { currentBet: 0, lastRaiseSize: 0, actingSeatIndex: null, + // Whether the betting has actually been opened for this hand. + // + // Dealing and betting used to be one button: "Next Hand" moved the button + // and posted the blinds in the same breath. That meant the moment between + // hands — the one where people are still sitting down, standing up and + // arguing about whose turn it is — was spent with money already committed, + // and the blind badges recomputing off `inHand` while the posted chips + // stayed frozen from when the hand began. Seat somebody after pressing it + // and the table would show a big blind who had posted nothing. + // + // So the dealer says when. False means the table is being set up: nothing + // is committed, the badges are a preview, and seating or moving the button + // costs nothing. + bettingOpen: false, // Set when the dealer names the blinds by hand instead of letting them // follow the button — { sbIndex, bbIndex }. Home games do this constantly // ("you had it last time", someone arriving late owing a blind), and the @@ -84,6 +98,17 @@ const HoldemState = { .forEach(key => { if (parsed[key] === undefined) parsed[key] = defaults[key]; }); + + // 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, + // means the betting was open when they closed the tab. + if (parsed.bettingOpen === undefined) { + parsed.bettingOpen = + parsed.actingSeatIndex !== null && parsed.actingSeatIndex !== undefined || + parsed.seats.some(p => + (Number(p.committed) || 0) > 0 || (Number(p.amountOverride) || 0) > 0); + } parsed.seats.forEach(player => { if (player.stack === undefined) player.stack = null; if (player.committed === undefined) player.committed = 0; @@ -306,6 +331,12 @@ const HoldemState = { startNextHand(state) { this.advanceButton(state); + // Cards aren't out and nothing is posted until the dealer says so. + state.bettingOpen = false; + state.actingSeatIndex = null; + state.street = "preflop"; + state.currentBet = 0; + state.lastRaiseSize = 0; // A hand-picked blind applies to the hand it was picked for. Carrying it into // the next one would freeze the blinds on two players for the rest of the // night, which is the opposite of what naming them once is for. diff --git a/tests/holdem/dealStep.test.js b/tests/holdem/dealStep.test.js new file mode 100644 index 0000000..86874a1 --- /dev/null +++ b/tests/holdem/dealStep.test.js @@ -0,0 +1,163 @@ +// Dealing and betting are two decisions, and fusing them meant the table guessed. +// +// The moment between hands is when people sit down, stand up and argue about whose +// turn it is. With blinds posted the instant the previous hand ended, the blind +// badges recomputed off `inHand` while the committed chips stayed frozen — so seating +// somebody produced a big blind who had posted nothing. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load"); + +const postedBy = state => state.seats + .map((p, i) => (Betting.committed(p) > 0 ? i : null)) + .filter(i => i !== null); + +describe("the table waits to be dealt", () => { + test("finishing a hand leaves the next one un-dealt", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + Betting.beginHand(state); + assert.equal(state.bettingOpen, true); + + HoldemState.startNextHand(state); + + assert.equal(state.bettingOpen, false, "the next hand hasn't been dealt"); + assert.equal(state.actingSeatIndex, null, "nobody is on the clock"); + assert.deepEqual(postedBy(state), [], "and no blind has been committed"); + }); + + test("no money moves until the dealer deals", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + const start = chipsOnTable(state); + HoldemState.startNextHand(state); + + assert.equal(chipsOnTable(state), start); + assert.equal(HoldemState.totalPot(state), 0); + + Betting.beginHand(state); + assert.equal(state.bettingOpen, true); + assert.equal(postedBy(state).length, 2, "now the blinds are in"); + assert.equal(chipsOnTable(state), start, "and nothing was created doing it"); + }); +}); + +describe("setting the table costs nothing", () => { + test("seating a player before the deal changes who posts", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + HoldemState.startNextHand(state); + + const before = Betting.blindPreview(state); + state.seats[3].inHand = true; + state.seats[3].stack = 100; + const after = Betting.blindPreview(state); + + assert.notDeepEqual( + [before.sbIndex, before.bbIndex], + [after.sbIndex, after.bbIndex], + "the preview follows the new table" + ); + assert.deepEqual(postedBy(state), [], "and still nothing is committed"); + + Betting.beginHand(state); + assert.deepEqual(postedBy(state).sort(), [after.sbIndex, after.bbIndex].sort(), + "the money lands where the preview said it would"); + }); + + test("removing a player before the deal can't strand their blind", () => { + // The old failure: take out the big blind after the hand had begun and their + // chips stayed in the pot while the badge moved to somebody who'd posted + // nothing. Before the deal there is nothing to strand. + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + HoldemState.startNextHand(state); + + const doomed = Betting.blindPreview(state).bbIndex; + state.seats[doomed].inHand = false; + + assert.deepEqual(postedBy(state), [], "nobody has posted, so nothing is stranded"); + + Betting.beginHand(state); + const posted = postedBy(state); + assert.ok(!posted.includes(doomed), "the player who left posts nothing"); + posted.forEach(i => assert.ok(state.seats[i].inHand, "only players in the hand post")); + }); + + test("moving the button before the deal just moves it", () => { + const state = makeTable({ seatCount: 8, seated: [0, 2, 4, 6], dealerSeatIndex: 0 }); + const start = chipsOnTable(state); + HoldemState.startNextHand(state); + + [2, 4, 6, 0].forEach(seat => { + state.dealerSeatIndex = seat; + assert.deepEqual(postedBy(state), [], `passing the button to seat ${seat + 1} posted something`); + assert.equal(chipsOnTable(state), start); + }); + + Betting.beginHand(state); + assert.equal(chipsOnTable(state), start, "dealing moves chips between stacks, not into thin air"); + }); + + test("what the preview promises is what gets posted", () => { + const state = makeTable({ seatCount: 9, seated: [1, 3, 6, 8], dealerSeatIndex: 1 }); + HoldemState.startNextHand(state); + + const preview = Betting.blindPreview(state); + Betting.beginHand(state); + + assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind); + assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind); + }); + + test("there's nothing to preview without two players", () => { + const alone = makeTable({ seatCount: 6, seated: [2] }); + HoldemState.startNextHand(alone); + assert.equal(Betting.blindPreview(alone), null); + + const empty = makeTable({ seatCount: 6, seated: [] }); + HoldemState.startNextHand(empty); + assert.equal(Betting.blindPreview(empty), null); + }); + + test("a hand-picked blind shows up in the preview", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + HoldemState.startNextHand(state); + state.blindOverride = { sbIndex: 3, bbIndex: 0 }; + + const preview = Betting.blindPreview(state); + assert.equal(preview.manual, true); + assert.equal(preview.sbIndex, 3); + assert.equal(preview.bbIndex, 0); + }); +}); + +describe("a table saved mid-hand comes back mid-hand", () => { + const loadWith = saved => { + const original = globalThis.localStorage; + globalThis.localStorage = { getItem: () => JSON.stringify(saved), setItem: () => {}, removeItem: () => {} }; + const state = HoldemState.load(); + globalThis.localStorage = original; + return state; + }; + + test("chips already committed mean the betting was open", () => { + const state = loadWith({ + seats: [{ id: "a", name: "Ann", inHand: true, committed: 2 }, { id: "b", name: "Ben", inHand: true, committed: 0 }], + handNumber: 3, + }); + assert.equal(state.bettingOpen, true, "otherwise it would offer to deal over a live hand"); + }); + + test("somebody on the clock means the betting was open", () => { + const state = loadWith({ + seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }], + actingSeatIndex: 1, handNumber: 2, + }); + assert.equal(state.bettingOpen, true); + }); + + test("a quiet table comes back waiting to be dealt", () => { + const state = loadWith({ + seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }], + actingSeatIndex: null, handNumber: 5, + }); + assert.equal(state.bettingOpen, false); + }); +}); -- 2.43.0 From c77161c3e99feb9319d712b5c7fba911e00b607a Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 10:06:58 -0600 Subject: [PATCH 2/4] 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); + }); +}); -- 2.43.0 From e01d8e7c49c52ce2c1dde643cb79711cfbe43e74 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 10:24:14 -0600 Subject: [PATCH 3/4] Tapping a seat gives you that seat Went to audit the seat editor and couldn't open it: Playwright refused to click the middle of seat 1 because seat 9's dealer chip was sitting there. On a nine-seat table, tapping the centre of one player handed the button to another. I shipped that. The enlarged chip hit area grew outward from a chip already hung over its seat's corner at -10px/-10px -- and on an oval, a seat's corner is the seat next to it. Measured on the nine-seat table: seat 1 10 of 49 sampled points taken by seat 9's chip, including its centre seat 4 9 of 49 taken by seat 5's chip seat 5 3 of 49 taken by seat 6's chip I claimed at the time that no seat tap was stolen, and I had checked -- with an audit that exempted anything inside a `.seat` as harmless crowding. The thief was inside a seat. The check walked straight past it. Two fixes, and the second matters more The chip sits inside its seat now rather than on the rim, and its hit area grows inward over its own seat, which is a big target that can spare a corner. Padding inward alone wasn't enough -- I tried it and the numbers didn't move, because the chip's own box was the problem, not just the pad. Isolating that took disabling the pad and then the overhang separately: pad off gave 2 seats affected, overhang off gave zero. And the audit now asks whether a seat's centre belongs to that seat, as its own check. The occlusion check couldn't answer it: it was built for overlays on top of the table, and this was two pieces of the table fighting over a tap. It exempts a neighbour's body -- seats do overlap by design -- but not a neighbour's controls, which was the exact hole. Seven of eight sizes now have no seat whose centre goes elsewhere, and all four seats I tap open their own editor. What's left, stated rather than buried At 360x640 and 375x667 one seat still loses a single sample point of its rim to a neighbour's chip. Centres are correct; this is the rim of a crowded oval. At 844x390 two seats have centres belonging to the seat next door -- and those are seat bodies, not chips. Nine seats on a 163px-tall landscape table overlap almost completely. That's the landscape felt sizing, it predates all of this, and it wants fixing on its own terms rather than being folded in here. Both are now reported by the audit instead of hidden by it, which is the part I actually trust. 152 tests still passing; contrast clean at every size. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 33 +++++++++++--- tests/holdem/uiAudit.js | 49 ++++++++++++++++++++- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index c592157..3fb5756 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -568,8 +568,19 @@ button, .dealerChip { position: absolute; - top: -10px; - right: -10px; + /* Inside its own seat, not on the rim. + + It used to sit at -10px/-10px, hanging over the seat's corner — which on an oval + of overlapping seats means hanging over the seat *next to it*. Measured on a + nine-seat table: tapping the centre of seat 1 gave the button to seat 9. That + was always slightly true and the enlarged hit area made it much worse; three of + nine seats were losing taps, one of them losing its own middle. + + Tucked inside, the overlap is zero at every size. It reads as a puck in the + corner of the seat rather than on its edge, which is a small price for tapping + a player and getting that player. */ + top: 3px; + right: 3px; width: 22px; height: 22px; border-radius: 50%; @@ -595,10 +606,20 @@ button, .dealerChip::after { content: ""; position: absolute; - top: -9px; - right: -7px; - bottom: -15px; - left: -14px; + /* Grows inward, over its own seat, not outward onto the neighbours. + + The chip already sits outside its seat by design — top:-10px, right:-10px — so + a pad that also reaches up and right lands on whatever seat is next around the + oval. It did: three of nine seats had taps taken by a neighbour's chip, seat 1 + losing 10 of 49 sampled points including its own centre, which meant tapping + the middle of seat 1 gave the button to seat 9. + + Down and left is its own seat, which is a large target that can spare the + corner. The two pixels of outward reach are just to cover the chip's border. */ + top: -3px; + right: -3px; + bottom: -18px; + left: -18px; } .dealerChip:focus-visible { diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 98e91e3..bcd0b86 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -133,7 +133,18 @@ const LayoutAudit = { if (el.contains(top) || top === el || top.contains(el)) return; // Seats ring a round table and overlap each other at the rim by // design; that is crowding, not an overlay hiding a control. - if (top.closest && top.closest(".seat")) return; + // + // But only the seat's *body* is harmless. A neighbouring seat's own + // controls sitting on top of this one is a control being stolen, and + // exempting the whole seat subtree hid exactly that: an enlarged + // dealer-chip hit area was taking the centre of the seat three + // places round the table, and this check called it crowding and + // moved on. + const neighbourSeat = top.closest && top.closest(".seat"); + if (neighbourSeat) { + const isControl = top.matches && top.matches("button, a, input, select, textarea, [role='button']"); + if (!isControl) return; + } blocked++; if (!blockedBy) blockedBy = this.describe(top); }); @@ -468,6 +479,41 @@ const LayoutAudit = { return { name: `touch targets at least ${minimum}px`, offenders }; }, + // Tapping the middle of a seat must give you that seat. + // + // Added after a dealer-chip hit area, meant to make a 22px puck thumb-sized, grew + // outward from a chip that already hung over its seat's corner — and therefore + // over the seat next to it. On a nine-seat table, tapping the centre of seat 1 + // handed the button to seat 9. The occlusion check missed it because it treated + // anything inside a `.seat` as harmless crowding. + // + // Reported separately from occlusion because the cause is different: not an + // overlay on top of the table, but two pieces of the table fighting over a tap. + seatOwnership() { + const seats = [...document.querySelectorAll(".seat")]; + const offenders = []; + + seats.forEach((seat, index) => { + const rect = seat.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return; + + const hit = document.elementFromPoint(x, y); + if (!hit || seat.contains(hit) || hit === seat) return; + + const other = hit.closest(".seat"); + const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit); + const control = hit.matches && hit.matches("button, a, input, select, [role='button']"); + offenders.push( + `seat ${index + 1}'s centre goes to ${who}${control ? "'s " + this.describe(hit) : ""}` + ); + }); + + return { name: "a seat's centre belongs to that seat", offenders }; + }, + // ---- Runner ---- runAll(options = {}) { @@ -480,6 +526,7 @@ const LayoutAudit = { this.textSpill(), this.horizontalOverflow(), this.fieldFontSize(), + this.seatOwnership(), ]; if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget)); -- 2.43.0 From 0c6cd559514550e8846d299d3eb721eb8f4b8c40 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 10:39:45 -0600 Subject: [PATCH 4/4] Let the landscape table use the width it has Last round I left a residual and said it wanted its own fix: on a phone held sideways, seats overlapped so badly that a seat's centre belonged to the seat next door. Here's the cause. The landscape rule capped the felt's width by its height to hold the portrait aspect ratio. At 640x360 that produced a table 135px wide inside a 601px column -- 22% of the screen -- with 366px of rim for nine seats needing 662px. Adjacent seat centres ended up 13px apart for seats rendering 74px wide. Landscape has width and no height, so the table now takes the width it has and is only as tall as the room left over. A wide ellipse, which is what a real poker table is anyway. 640x360 felt 135x97 -> 601x121 rim 366 -> 1263 centres 13px -> 33px 844x390 felt 176x127 -> 765x151 rim 480 -> 1605 centres 37px -> 59px 915x412 felt 207x149 -> 492x173 rim 563 -> 1106 centres 44px -> 55px Seat ownership went from 8 seats wrong at 640x360 and 2 at 844x390 to zero at 844x390, zero at 915x412, and two at the very smallest. Nine of eleven sizes clean, portrait and desktop untouched. Two things I tried and threw away Spacing seats by arc length instead of by angle. It sounds obviously right -- even distance around the rim rather than even angle -- and it made things worse: 640x360 went from 2 wrong to 3, closest centres 33px to 21px, and even the desktop table dropped from 180px to 155px. Even arc spacing packs seats through the tight turns at the ends of an ellipse, where a fixed distance along the rim covers much less straight-line distance. Equal angles happen to separate them better. Reverted. Shrinking seats on the tightest landscape phones. Took the rendered seat from 74px to 64px and moved closest centres not at all -- still 33px -- while the covered count at 844x390 went from 3 to 4. A change that measures as nothing but costs CSS and legibility isn't worth keeping. Reverted. So this is one CSS rule, which is all the measurements supported. What's left 640x360 and 667x375 still have two seats whose centre lands on a neighbour. Nine seats on a ring 121px tall is a genuine physical constraint rather than a layout mistake, and both remaining fixes I tried made it worse rather than better. It is reported by the audit rather than hidden, and a dealer on a 360px-tall screen has the seat menu and the action bar for everything that matters. 152 tests passing; contrast clean at every size; dealing still works in landscape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 3fb5756..8b31fb5 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2808,9 +2808,20 @@ body.hasActionBar #toastHost { against the containing block's width, not the element's own, so narrowing the wrap on its own left it full height regardless. */ #pokerTableWrap { - --feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem); - width: min(100%, calc(var(--feltRoom) / 0.72)); + /* A wide ellipse, which is what a real poker table is. + + Capping the width by the height to hold the portrait aspect ratio was + throwing away most of the screen: at 640x360 the felt came out 135px wide + inside a 601px column — 22% of the width — leaving 366px of rim for nine + seats that need 662px. Adjacent seat centres ended up 13px apart, which is + why a seat's centre belonged to its neighbour. + + Landscape has width and no height, so the table takes the width it has and + is only as tall as the room left over. */ + --feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 7rem); + width: 100%; height: var(--feltRoom); + min-height: 7rem; padding-bottom: 0; } @@ -2847,6 +2858,7 @@ body.hasActionBar #toastHost { width: 62px; min-height: 40px; } + } /* ---------- Short phones, upright ---------- -- 2.43.0