From f86e50268ee4d8a4d56419fdebfed2c3de7901f3 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Mon, 14 Sep 2026 04:17:32 -0600 Subject: [PATCH] Stop calling it the next hand when it deals no hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Next hand" named three things: the bar's green button, the pot wizard's last button, and the header shortcut — the only one of the three that never starts a hand. Between hands it moves the button a second time, so the seat about to pay the big blind is skipped, and the toast opened with the words the wizard had just used, which read as a confirmation of what was already done. Measured: settle through the wizard and the table is on hand 6 with the button on seat 2; press the header's Next Hand and it is still hand 6, button on seat 3, no confirmation asked. The mechanism stays — a dealer does pass the button along on a hand nobody played. The name changes. The label and its title are now read off whether a hand is out: "Next Hand ⟳" while one is, "Move Button ⟳" when none is, written past the dimmed-reason cache each render so they follow the table instead of freezing on the state the page loaded in. The toast says which of the two happened, on one line: the second line told the dealer to press the green button the bar is already holding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 7 +- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 9 +- .../wwwroot/js/casino/holdem/main.js | 16 +- .../wwwroot/js/casino/holdem/render.js | 24 +++ tests/holdem/theButtonThatDoesNotDeal.test.js | 184 ++++++++++++++++++ tests/holdem/theHeader.test.js | 6 +- 6 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 tests/holdem/theButtonThatDoesNotDeal.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 29fc0f0..ee8482b 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -26,7 +26,10 @@
- + @* Both the label and the title are rewritten every render from whether a hand + is out — see Render.renderHeaderActions. What is here is what is true before + the first render: no hand, so pressing it only moves the button. *@ + @* Not the accent any more. It is a shortcut to a sheet, available all night, and it is never the next step — the bar always has that, and at showdown the bar says so in as many words. See .actionAdvance in holdem.css. *@ @@ -44,7 +47,7 @@ Not an `*Overlay` id, deliberately — Modal watches those and would take the sidebar panel this becomes on a desktop for a dialog. It is driven entirely by - body.seatSheetOpen, and it is `display: none` above 700px where the panel is + body.seatSheetOpen, and it is `display: none` above 900px where the panel is just a panel. *@
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index c73888f..860a271 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -5306,9 +5306,12 @@ body.hasActionBar #toastHost { } /* Grow into that spare width, never shrink below the label. `flex: 1 1 0` would - divide the row into equal thirds and squeeze "Next Hand ⟳" — 105px of text — into - 99, and these are `white-space: nowrap`, so what gives is the text. An `auto` - basis makes the label the floor and the spare width the bonus. */ + divide the row into equal shares: at 360px the row is 269 wide, so mid-hand — + Misdeal, Next Hand, Resolve Pot — that is 87px each, and "Next Hand ⟳" needs 73 of + text plus 16 of padding. These are `white-space: nowrap`, so what gives is the + text. An `auto` basis makes the label the floor and the spare width the bonus, + which is also what lets the middle button carry the longer of its two names — + "Move Button ⟳", 100px of box, the one it wears between hands. */ #holdemHeaderActions .holdemBtn { flex: 1 1 auto; justify-content: center; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index f600b52..a5139c3 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -641,6 +641,10 @@ document.addEventListener("DOMContentLoaded", () => { // middle took the table from 800 chips to 792, and the toast said nothing // about it. Chips only ever leave this tool because a dealer meant them to. const unresolved = HoldemState.unresolvedContributions(state); + // Asked before the mutation, because after it there is never a hand out. This is + // the difference between the two things this one button does: end the hand that + // is running, or pass the button along on a hand nobody played. + const wasLive = !!state.bettingOpen; if (unresolved > Betting.EPSILON_CHIPS) { const ok = await Dialog.confirm({ @@ -669,7 +673,17 @@ document.addEventListener("DOMContentLoaded", () => { ? ` ${Money.format(unresolved)} taken off the table.` : ""; - runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => { + // The toast said "Next hand — ... Seat the table, then deal." every time, including + // the press that followed the pot wizard's own "Start Next Hand" — two lines that + // read as a confirmation of the thing the dealer had already done, when what had + // actually happened was a second rotation. It now reports which of the two it was, + // and the second line is gone: the bar is already holding a green "Post blinds & + // deal", so the toast was spending a line telling a dealer to press it. + const done = wasLive + ? `Next hand — ${where}.${discarded}` + : `Button moved on, no hand dealt — ${where}.${discarded}`; + + runUndoable(done, () => { // 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. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index f56e3f4..9f05947 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -262,6 +262,30 @@ const Render = { if (!misdeal) return; misdeal.hidden = !this.state.bettingOpen; + // "Next hand" names three different things in this tool: the bar's green button + // deals one, the pot wizard finishes on "Start Next Hand", and this header + // shortcut — the only one of the three that does not start a hand. Between hands + // it moves the button a second time, so the seat that just paid the big blind + // pays it again, and it said so in a toast that opens with the same two words + // the wizard just used. Measured: wizard → hand 6, button seat 2; press this → + // still hand 6, button seat 3, no confirmation asked. + // + // The mechanism is right — a dealer does pass the button along on a hand nobody + // played — so what changes is the name. A command is named for the state the + // system moves into (NN/g, Button States: "Show Tab Bar" / "Hide Tab Bar"), and + // between hands the state it moves into is a moved button, not a new hand. + const nextHand = document.getElementById("nextHandBtn"); + if (nextHand) { + const live = !!this.state.bettingOpen; + nextHand.textContent = live ? "Next Hand ⟳" : "Move Button ⟳"; + // Written every render, ahead of the borrow-and-hand-back below: that cache + // is filled once from the markup, and a title that depends on the hand would + // freeze on whichever one happened to be true at load. + nextHand.dataset.titleWhenUsable = live + ? "End this hand and move the button, small blind and big blind to the next occupied seat" + : "Pass the button on again without dealing — for a hand nobody played"; + } + // On a table nobody is sitting at, Next Hand has no button to move and Resolve // Pot has no pot, so both are visibly not-yet rather than sitting there looking // ready. diff --git a/tests/holdem/theButtonThatDoesNotDeal.test.js b/tests/holdem/theButtonThatDoesNotDeal.test.js new file mode 100644 index 0000000..dc6a0cd --- /dev/null +++ b/tests/holdem/theButtonThatDoesNotDeal.test.js @@ -0,0 +1,184 @@ +// The one control in the header that is named for a hand and never starts one. +// +// "Next hand" names three different actions in this tool. The action bar's green button +// deals one. The pot wizard finishes on "Start Next Hand" and hands back a table ready +// to deal. And the header shortcut — which, between hands, deals nothing: it moves the +// button on a second time, so the seat that was about to pay the big blind is skipped +// and the next one pays it instead. +// +// Measured before this changed: settle a pot through the wizard, and the table is on +// hand 6 with the button on seat 2 and the bar offering "Post blinds & deal". Press the +// header's "Next Hand ⟳" — no confirmation, because there is nothing in the middle to +// warn about — and the table is still on hand 6, with the button on seat 3. The toast +// then opened with the same two words the wizard's button had just used, which reads as +// a confirmation of the thing that was already done rather than a report of a second +// rotation. +// +// The mechanism is right and stays: a dealer does pass the button along on a hand nobody +// played, and state.startNextHand says so in as many words. What was wrong was the name. +// A command is named for the state the system moves into — NN/g's Button States, "Show +// Tab Bar" against "Hide Tab Bar" — and between hands the state this moves into is a +// moved button, not a new hand. +// +// So the label is read off the hand: "Next Hand ⟳" while one is out, "Move Button ⟳" +// when none is. Both claims are checked here, along with the trap underneath them — the +// title is cached once from the markup for the dimmed-reason machinery to borrow and +// hand back, and a title that depends on the hand has to be written past that cache on +// every render or it freezes on whichever state happened to be true at load. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Render, HoldemState, Betting, makeTable } = require("./load"); + +// Stand-ins for the three header buttons. Enough of an element for Unavailable and for +// renderHeaderActions: attributes, a dataset, text, and a hidden flag. +const element = title => { + const attributes = title ? { title } : {}; + return { + hidden: false, + textContent: "", + dataset: {}, + getAttribute: name => (name in attributes ? attributes[name] : null), + setAttribute: (name, value) => { attributes[name] = String(value); }, + removeAttribute: name => { delete attributes[name]; }, + }; +}; + +// The header as the page ships it, with the markup's own title on the shortcut. +const header = () => { + const buttons = { + misdealBtn: element("Take this hand back"), + nextHandBtn: element("Pass the button on again without dealing — for a hand nobody played"), + resolvePotBtn: element(""), + }; + global.document = { getElementById: id => buttons[id] || null }; + return buttons; +}; + +const renderOn = state => { + const before = Render.state; + Render.state = state; + try { Render.renderHeaderActions(); } finally { Render.state = before; } +}; + +const table = () => makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 5, bigBlind: 10 }); + +test.afterEach(() => { delete global.document; }); + +describe("what the shortcut calls itself", () => { + test("between hands it offers to move the button, because that is all it does", () => { + const buttons = header(); + renderOn(table()); + + assert.equal(buttons.nextHandBtn.textContent, "Move Button ⟳"); + assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/, + "the title has to say the thing the label can't fit"); + }); + + test("and with a hand out it offers the next one, because that is what it moves to", () => { + const buttons = header(); + const state = table(); + Betting.beginHand(state); + state.bettingOpen = true; + renderOn(state); + + assert.equal(buttons.nextHandBtn.textContent, "Next Hand ⟳"); + assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/); + }); + + test("the name follows the table rather than sticking where it started", () => { + // The failure this guards is a one-way trip: the label is right on the state the + // page loaded in and wrong for the rest of the night. + const buttons = header(); + const state = table(); + + renderOn(state); + const betweenHands = buttons.nextHandBtn.textContent; + Betting.beginHand(state); + state.bettingOpen = true; + renderOn(state); + const midHand = buttons.nextHandBtn.textContent; + HoldemState.startNextHand(state); + renderOn(state); + + assert.notEqual(betweenHands, midHand, "the label never moved off the loaded state"); + assert.equal(buttons.nextHandBtn.textContent, betweenHands, "and it never came back"); + }); + + test("including the title, which the dimmed-reason cache would otherwise freeze", () => { + // renderHeaderActions fills dataset.titleWhenUsable once, from the markup, so the + // sentence hung off a dimmed control can be handed back when it is usable again. + // A state-dependent title has to be written into that slot on every render — this + // is the assertion that the write happens before the borrow, not after it. + const buttons = header(); + const state = table(); + + renderOn(state); + const betweenHands = buttons.nextHandBtn.getAttribute("title"); + Betting.beginHand(state); + state.bettingOpen = true; + renderOn(state); + + assert.notEqual(buttons.nextHandBtn.getAttribute("title"), betweenHands); + assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/); + }); + + test("and an empty table still gets the reason it can't be pressed", () => { + // Naming the two states must not cost the third one its sentence: with nobody + // seated there is no button to move, and that is what the title says. + const buttons = header(); + // A table as the tool first opens it: chairs, and nobody named in any of them. + renderOn(HoldemState.createDefault()); + + assert.match(buttons.nextHandBtn.getAttribute("title"), /seat a player first/); + + // And seating the table hands the state's own sentence back. + renderOn(table()); + assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/); + }); + + test("Misdeal still comes and goes with the hand", () => { + const buttons = header(); + const state = table(); + + renderOn(state); + assert.equal(buttons.misdealBtn.hidden, true, "there is nothing to take back yet"); + + Betting.beginHand(state); + state.bettingOpen = true; + renderOn(state); + assert.equal(buttons.misdealBtn.hidden, false); + }); +}); + +describe("what it does when there is no hand to end", () => { + test("the button moves on and the hand number doesn't", () => { + // This is the behaviour the label now names, pinned so the name stays true. + const state = table(); + state.handNumber = 6; + state.dealerSeatIndex = 1; + + HoldemState.startNextHand(state); + + assert.equal(state.dealerSeatIndex, 2, "the button passed to the next occupied seat"); + assert.equal(state.handNumber, 6, "no hand was dealt, so none was counted"); + assert.equal(state.bettingOpen, false); + assert.equal(state.postedBlinds, null, "nothing is posted until the dealer says so"); + }); + + test("so pressing it twice skips a seat's big blind", () => { + // Not a bug — a dealer passing the button along twice means it — but it is the + // reason the control cannot be called "Next Hand" while it is doing it. + const state = table(); + const first = HoldemState.getBlindSeats({ ...state }); + + HoldemState.startNextHand(state); + const second = HoldemState.getBlindSeats(state); + HoldemState.startNextHand(state); + const third = HoldemState.getBlindSeats(state); + + assert.notEqual(second.bbIndex, first.bbIndex); + assert.notEqual(third.bbIndex, second.bbIndex, + "the seat that was about to pay the big blind never did"); + }); +}); diff --git a/tests/holdem/theHeader.test.js b/tests/holdem/theHeader.test.js index 94972cb..4ecc46f 100644 --- a/tests/holdem/theHeader.test.js +++ b/tests/holdem/theHeader.test.js @@ -97,9 +97,9 @@ describe("the header is one row on a phone", () => { }); test("and the buttons grow into that width without ever shrinking below their labels", () => { - // `flex: 1 1 0` would divide the row into equal thirds and squeeze "Next Hand ⟳" - // — 105px of text — into 99. These are `white-space: nowrap`, so what gives is - // the text. + // `flex: 1 1 0` would divide the row into equal shares: at 360px the row is 269 + // wide, so mid-hand that is 87px each and "Next Hand ⟳" needs 89 — 73 of text and + // 16 of padding. These are `white-space: nowrap`, so what gives is the text. const rule = ruleIn(PHONE(), "#holdemHeaderActions .holdemBtn") || ""; assert.match(rule, /flex:\s*1\s+1\s+auto/, "an auto basis makes the label the floor"); assert.match(rule, /white-space:\s*nowrap/);