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 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
164 lines
6.8 KiB
JavaScript
164 lines
6.8 KiB
JavaScript
// 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);
|
|
});
|
|
});
|