Files
JoshHeaps.Net/tests/holdem/dealStep.test.js
jheapsandClaude Opus 5 c77161c3e9 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:06:58 -06:00

206 lines
8.7 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);
});
});
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);
});
});