Stop calling it the next hand when it deals no hand
"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 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -26,7 +26,10 @@
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
@* 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. *@
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Pass the button on again without dealing — for a hand nobody played">Move Button ⟳</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. *@
|
||||
<div id="seatSheetScrim"></div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user