Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 8efc14f61f Show the banker the amount to count out, not just whether the player is up
The standings had a column headed "Cash out" and it did not hold an amount to pay.
It held the difference against the player's buy-in. Measured on a table where Ann
bought in for $200 and sits behind $340:

    PLAYER   NET   CASH OUT
    Ann      $0     +$140

She is handed $340. The column called "Cash out" says $140, under a heading that
invites reading it as the payout, at the exact moment somebody is counting money onto
the table. The number a banker's hands actually follow — what the player is holding —
was computed all along and written to the CSV, and never shown on screen.

Three columns now, and they say what they are: Player, Chips, Up/down. Chips is a
plain amount and never signed, because it is a quantity of money to hand over. Up/down
keeps the sign and the colour, because it is a result. The two differ by exactly the
buy-in, which is asserted rather than assumed.

Chips counts what is out in the middle for the hand in progress as still theirs, for
the same reason the up/down figure does: the panel is read mid-hand constantly and a
stack that drops by the size of a bet looks like money going missing.

Net — won less paid in, from the hand log — comes off the screen to make room. It is
the weakest of the three: it only moves when a pot is resolved through the wizard, so
at a table where the dealer settles pots by hand it reads $0 all night beside two
columns that don't, and up/down already answers "how are they doing". It stays in the
CSV export, which loses nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 23:36:14 -06:00
jheapsandClaude Opus 5 c70cb150db Stop telling a brand-new table its players are broke, and give it a way out
Following on from seating the table: a dealer who names six people and hasn't yet
said what a buy-in is gets told

    Nobody at the table has chips left. Add a buy-in to deal.

with no buttons on the bar at all. Both halves are wrong for that table. "Left" says
the chips were had and lost, which sends the dealer looking for a rebuy — and a
blocked prompt with nothing to press is a dead end, when the fix is one field in the
sidebar the message never pointed at.

A table where nobody has ever bought in is now told what is actually true, and given
the way out:

    Nobody has bought in yet. Set what everyone starts with, or give each seat
    their chips.                                      [ Set starting stack ]

The button opens Table Setup, scrolls the starting stack into view and selects it, so
the next thing typed is the answer. A seat with no stack of its own falls back to the
table default, which is why one number is enough to seat everybody's chips at once.

A table that genuinely busted out is a different situation and still says so, with no
starting-stack button — there the fix is a rebuy, not a default.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 23:20:13 -06:00
jheapsandClaude Opus 5 e9bc9afcdb Seat a player when the dealer names one, and stop hiding the bar on a full table
The first thing anybody does with this tool is seat the table, and it didn't work.
Measured on a fresh table: click a seat, type a name, repeat six times — six names on
the felt, six seats still not in the hand, and no action bar at all. No "post blinds
and deal", no explanation, nothing to press. Six people at the table and a blank
screen with no way forward.

Three things were wrong and each hid the next.

Naming a chair and dealing that chair in were separate acts. Putting a name on an
empty chair is somebody sitting down, so it now deals them in. Only a chair that was
empty — no name and nobody in it: renaming someone who is sitting out is a correction,
not an arrival, and dealing them back in over a typo would be the tool overruling the
dealer.

The action bar hid itself whenever no seat was live, which was written for an empty
felt and caught a named one too. A table with names on it is not an empty table; it's
a table the dealer needs to be told something about.

And the seat editor never redrew the bar. It refreshes narrowly on purpose — a full
render on every keystroke would take the name field out from under the dealer
mid-word — but the bar answers "is there a table here at all", and seating somebody is
exactly what changes that answer. Even ticking "In hand" by hand left the bar hidden
until something else happened to redraw. Verified the fix keeps focus and the caret
where they were, typing a character at a time.

For the states that can still reach it — a table where everyone was sat out, a save
from before naming seated people — the prompt names them and offers "Deal in all 6"
rather than a hunt through six seat editors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 23:08:42 -06:00
8 changed files with 378 additions and 15 deletions
@@ -110,8 +110,9 @@
<span>Player</span>
<span class="standingsInHead">In</span>
<span class="standingsWonHead">Won</span>
<span>Net</span>
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
<span class="standingsNetHead">Net</span>
<span class="standingsChipsHead" title="What they are holding — the cash to count out if they leave now">Chips</span>
<span title="Those chips, less the cash they handed over">Up/down</span>
</div>
<div id="standingsList"></div>
</div>
@@ -270,6 +271,7 @@
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsChips"></span>
<span class="standingsCashOut"></span>
</div>
</template>
+21 -8
View File
@@ -1816,31 +1816,44 @@ button,
white-space: nowrap;
}
/* The per-hand columns are detail: how the night's swings happened. They're the
first thing to go when there isn't room, because Net and Cash out are what the
banker is actually reading. */
/* The per-hand columns are detail: how the night's swings happened. They're the first
to go when there isn't room, and Net goes with them — it only moves when a pot is
resolved through the wizard, so on a table where the dealer settles pots by hand it
reads $0 all night beside two columns that don't.
What survives at every width is the pair the banker actually works from: what the
player is holding, and whether that leaves them up or down. */
.standingsIn,
.standingsWon,
.standingsNet,
.standingsInHead,
.standingsWonHead {
.standingsWonHead,
.standingsNetHead {
display: none;
}
@container (min-width: 30rem) {
.standingsRow {
grid-template-columns: minmax(5rem, 1fr) auto auto auto auto;
grid-template-columns: minmax(5rem, 1fr) auto auto auto auto auto;
}
.standingsIn,
.standingsWon,
.standingsNet,
.standingsInHead,
.standingsWonHead {
.standingsWonHead,
.standingsNetHead {
display: block;
}
}
/* The settle-up column is the one a banker reads while counting out cash, so it
carries the weight the per-hand columns don't. */
/* The chips column is the one a banker's hands follow while counting money onto the
table, so it carries the weight. Up/down is read once, at the end. */
.standingsChips {
font-weight: 600;
color: #e6e6e6;
}
.standingsCashOut {
font-weight: 600;
}
@@ -79,8 +79,14 @@ const ActionBar = {
// 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.
//
// A *named* table is not an empty one, though, and that was the hole: name six
// people without dealing any of them in and the bar hid itself, leaving a screen
// with six players on it, no way forward and nothing saying why. Anyone with a
// name on the felt is somebody the dealer has to be told about.
isRunning() {
return Betting.liveSeats(this.state).length > 0;
return Betting.liveSeats(this.state).length > 0 ||
this.state.seats.some(p => (p.name || "").trim());
},
render() {
@@ -587,10 +593,19 @@ const ActionBar = {
// thing: "only one player is dealt in" is wrong when four are sitting there
// and three of them are just out of chips.
const withChips = seated - broke.length;
// "Nobody has chips left" is the wrong story for a table that never had
// any. A dealer who has just named six people and not yet said what a
// buy-in is gets told their players are busted, which sends them looking
// for a rebuy button instead of the one field that fixes it.
const nobodyHasEverBoughtIn = this.state.seats.every(p =>
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
if (broke.length > 0 && withChips < 2) {
line.textContent = withChips === 1
? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
: "Nobody at the table has chips left. Add a buy-in to deal.";
: nobodyHasEverBoughtIn
? "Nobody has bought in yet. Set what everyone starts with, or give each seat their chips."
: "Nobody at the table has chips left. Add a buy-in to deal.";
} else {
line.textContent = seated === 0
? "Nobody is dealt in yet. Tap a seat to add a player."
@@ -598,6 +613,56 @@ const ActionBar = {
}
line.classList.add("dealPromptBlocked");
wrap.appendChild(line);
// A blocked prompt with nothing to press is a dead end, and this was the
// one a first-time dealer hits: six players named, a sentence saying they
// have no chips, and no button anywhere on the bar. The starting stack is a
// single field in the sidebar and the message never pointed at it.
if (nobodyHasEverBoughtIn && seated > 0) {
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Set starting stack", "actionDeal", () => {
const field = document.getElementById("defaultStackInput");
if (!field) return;
const panel = field.closest("details");
if (panel) panel.open = true;
field.scrollIntoView({ block: "center", behavior: "smooth" });
field.focus();
field.select();
}));
wrap.appendChild(row);
return wrap;
}
// Named seats that aren't in the hand. Naming a chair now seats whoever is
// in it, so reaching this takes something else — a table where everyone was
// sat out, or a save from before that was true — but the way out of it
// should still be one tap rather than a hunt through six seat editors.
const benched = this.state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
.map(({ index }) => index);
if (benched.length > 0) {
const aside = document.createElement("p");
aside.className = "dealPromptLine dealPromptAside dealPromptDebt";
const text = document.createElement("span");
text.textContent = benched.length === 1
? `${listNames(benched)} is sitting out.`
: `${listNames(benched)} are sitting out.`;
aside.appendChild(text);
aside.appendChild(this.button(
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
"dealPromptDebtBtn",
() => this.commit("dealing everybody in", () => {
benched.forEach(index => {
this.state.seats[index].inHand = true;
this.state.seats[index].folded = false;
});
})
));
wrap.appendChild(aside);
}
return wrap;
}
@@ -524,13 +524,41 @@ const Render = {
this.updatePotBadge();
this.renderSeatTotal(form, player);
this.refreshPuck(index);
// The editor deliberately refreshes narrowly rather than redrawing everything —
// a full render on every keystroke would take the name field out from under the
// dealer mid-word. But the action bar answers "is there a table here at all",
// and seating somebody is exactly what changes that answer. Without this it
// stayed hidden until something else happened to redraw: two players seated, a
// dealable hand, and a blank bar until the page was reloaded.
//
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
// anyway, and nothing inside it can hold focus while the sidebar has it.
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
// Putting a name on an empty chair is somebody sitting down, so they get
// dealt in. It used to be two separate acts, and the second one was easy to
// miss: a dealer setting up typed six names, got six seats that still
// weren't in the hand, and the action bar hid itself because by its reckoning
// nobody was playing. Six people at the table and a blank screen.
//
// Only for a chair that was empty — no name and not dealt in. Renaming
// somebody who is sitting out is a correction, not an arrival, and dealing
// them back in over a typo would be the tool overruling the dealer.
const wasEmpty = !player.name.trim() && !player.inHand;
player.name = nameInput.value;
if (wasEmpty && player.name.trim()) {
player.inHand = true;
player.folded = false;
const inHandBox = form.querySelector(".inHandCheckbox");
if (inHandBox) inHandBox.checked = true;
const foldBox = form.querySelector(".foldCheckbox");
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
}
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
@@ -790,9 +818,15 @@ const Render = {
netEl.classList.toggle("netUp", row.net > 0);
netEl.classList.toggle("netDown", row.net < 0);
// What they'd actually be handed if the night ended now: chips in front of
// them less the cash they gave the banker. This was computed and written to
// the CSV all along, but never shown to the person doing the paying out.
// The cash to count out, which is a plain amount and never signed — this
// is the one the banker's hands follow. It sat only in the CSV: on screen
// the column headed "Cash out" held the difference against their buy-in
// instead, so a player up $140 on a $340 stack read as "$140" at the exact
// moment somebody was counting money onto the table.
node.querySelector(".standingsChips").textContent = Money.format(row.chipsInFront);
// And whether that leaves them up or down, which is the question the rest
// of the table asks and the one the old column was actually answering.
const cashEl = node.querySelector(".standingsCashOut");
cashEl.textContent = Money.formatSigned(row.cashPosition);
cashEl.classList.toggle("netUp", row.cashPosition > 0);
@@ -683,6 +683,14 @@ const HoldemState = {
net: Money.round(t.won - t.in),
boughtIn,
chipsNow,
// Everything still theirs: the stack in front of them plus whatever
// is out in the middle for the hand in progress. This is the number
// a banker counts out when somebody leaves, and it was the one
// figure the standings never showed — the column called "Cash out"
// held the difference against their buy-in, so a player up $140 on
// a $340 stack read as "$140" at the exact moment cash changed
// hands.
chipsInFront: Money.round(chipsNow + inFrontOfThem),
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
notes,
};
+55
View File
@@ -167,3 +167,58 @@ describe("does the cash box cover the chips on the table", () => {
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
describe("the number the banker counts out", () => {
// The standings had two signed columns and neither was an amount to pay. A player
// up $140 on a $340 stack read "$140" under a heading saying "Cash out", at the
// exact moment somebody was counting money onto the table.
test("chips in front is what they are holding, not what they are up", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 340 });
state.seats[0].name = "Ann";
state.seats[0].buyIns = 200;
state.seats[1].buyIns = 200;
state.seats[1].stack = 60;
const rows = HoldemState.sessionStandings(state);
const ann = rows.find(r => r.name === "Ann");
assert.equal(ann.chipsInFront, 340, "the cash to count out");
assert.equal(ann.cashPosition, 140, "and separately, that they are up $140");
});
test("a player who is down still has chips to be handed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 60 });
state.seats[0].name = "Cal";
state.seats.forEach(p => { p.buyIns = 200; });
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Cal");
assert.equal(cal.chipsInFront, 60, "they leave with $60 of chips");
assert.equal(cal.cashPosition, -140, "having lost $140 of the $200 they brought");
});
test("chips still out in the middle are counted as theirs", () => {
// Cashing out mid-hand isn't a thing, but the panel is read mid-hand constantly
// and a stack that drops by the size of a bet looks like money going missing.
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
state.seats[0].name = "Dee";
state.seats.forEach(p => { p.buyIns = 200; });
Betting.beginHand(state);
Betting.raiseTo(state, 0, 50);
const dee = HoldemState.sessionStandings(state).find(r => r.name === "Dee");
assert.equal(dee.chipsInFront, 200, "stack plus what's in the middle for them");
assert.equal(dee.cashPosition, 0, "so they are still level");
});
test("chips and up/down always differ by exactly the buy-in", () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 100 });
[340, 150, 60, 250].forEach((chips, i) => {
state.seats[i].stack = chips;
state.seats[i].buyIns = 200 + i * 50;
});
HoldemState.sessionStandings(state).forEach(row => {
assert.equal(Money.round(row.chipsInFront - row.boughtIn), row.cashPosition,
`${row.name}'s two columns should tell one story`);
});
});
});
+5
View File
@@ -29,6 +29,10 @@ globalThis.Render = Render;
const HoldemState = require(path.join(SRC, "state.js"));
globalThis.HoldemState = HoldemState;
// actionBar.js after state, since its own module-level code reaches for neither but
// its methods reach for both. Loaded last so nothing it touches is still undefined.
const ActionBar = load("actionBar");
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -81,6 +85,7 @@ module.exports = {
SettleUp,
Betting,
HoldemState,
ActionBar,
makeTable,
chipsOnTable,
contributions,
+181
View File
@@ -0,0 +1,181 @@
// Sitting people down, which is the first thing anybody does with the tool.
//
// Naming a chair and dealing that chair in were two separate acts and the second was
// easy to miss. A dealer setting up typed six names, got six seats that still weren't
// in the hand, and the action bar hid itself because by its reckoning nobody was
// playing: six people at the table and a blank screen with no way forward.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, ActionBar, makeTable } = require("./load");
const emptyTable = (seatCount = 10) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, seatCount);
return state;
};
const bar = state => Object.assign(Object.create(ActionBar), { state });
describe("the bar knows the difference between an empty table and a named one", () => {
test("a table nobody has touched gets no bar", () => {
assert.equal(bar(emptyTable()).isRunning(), false, "an empty felt stays uncluttered");
});
test("a table with names on it gets one, dealt in or not", () => {
const state = emptyTable();
state.seats[0].name = "Ann";
state.seats[1].name = "Ben";
assert.equal(Betting.liveSeats(state).length, 0, "nobody is in the hand");
assert.equal(bar(state).isRunning(), true, "but there are people at the table to say something to");
});
test("whitespace isn't a player", () => {
const state = emptyTable();
state.seats[0].name = " ";
assert.equal(bar(state).isRunning(), false);
});
test("a table that is playing still gets one", () => {
assert.equal(bar(makeTable({ seatCount: 6, seated: [0, 1] })).isRunning(), true);
});
});
describe("naming an empty chair sits somebody down", () => {
// What the seat editor's name field does, as the rule it applies rather than
// through the DOM: a chair with no name and nobody in it is empty, and putting a
// name on it is somebody arriving.
const nameSeat = (player, name) => {
const wasEmpty = !player.name.trim() && !player.inHand;
player.name = name;
if (wasEmpty && player.name.trim()) { player.inHand = true; player.folded = false; }
};
test("an empty chair gets dealt in", () => {
const state = emptyTable();
nameSeat(state.seats[0], "Ann");
assert.equal(state.seats[0].inHand, true);
assert.equal(state.seats[0].folded, false);
});
test("six names is six players, not six benched seats", () => {
const state = emptyTable();
state.defaultStack = 200; // a table that has told the tool what a buy-in is
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => nameSeat(state.seats[i], name));
assert.equal(state.seats.filter(p => p.inHand).length, 6);
assert.equal(bar(state).isRunning(), true);
assert.ok(Betting.blindPreview(state), "and there's a hand to deal");
});
test("renaming somebody who is sitting out doesn't deal them back in", () => {
// A correction, not an arrival. Dealing them in over a typo would be the tool
// overruling a dealer who sat them out on purpose.
const state = emptyTable();
state.seats[0].name = "Ann";
state.seats[0].inHand = false;
nameSeat(state.seats[0], "Anne");
assert.equal(state.seats[0].inHand, false, "still sitting out, now spelled right");
});
test("clearing a name doesn't sit anybody down", () => {
const state = emptyTable();
nameSeat(state.seats[0], "Ann");
nameSeat(state.seats[0], "");
assert.equal(state.seats[0].name, "");
});
test("a chair already in the hand is left alone", () => {
const state = makeTable({ seatCount: 6, seated: [0] });
state.seats[0].folded = true;
nameSeat(state.seats[0], "Ann");
assert.equal(state.seats[0].folded, true, "mid-hand a fold is not undone by typing");
});
});
describe("the way back from a table where everybody is sat out", () => {
test("there are named seats to offer", () => {
// Can't be reached by naming any more, but a table where everyone was sat out
// — or a save from before naming seated people — still lands here.
const state = emptyTable();
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; });
const benched = state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
.map(({ index }) => index);
assert.deepEqual(benched, [0, 1, 2]);
assert.equal(Betting.blindPreview(state), null, "no hand to deal until they're in");
});
test("dealing them all in makes a hand possible", () => {
const state = emptyTable();
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].stack = 100; });
state.seats.forEach(player => {
if ((player.name || "").trim()) { player.inHand = true; player.folded = false; }
});
assert.equal(Betting.liveSeats(state).length, 3);
assert.ok(Betting.blindPreview(state), "the table can be dealt");
});
test("an unnamed chair is never swept in by it", () => {
const state = emptyTable();
state.seats[0].name = "Ann";
state.seats.forEach(player => {
if ((player.name || "").trim()) { player.inHand = true; }
});
assert.equal(state.seats.filter(p => p.inHand).length, 1, "empty chairs stay empty");
});
});
describe("a table where nobody has bought in", () => {
// Different from a table that has busted out, and the difference is the whole
// point: one needs a starting stack named, the other needs a rebuy. Telling a
// dealer their brand-new players have "no chips left" sends them hunting for the
// wrong thing.
const neverBoughtIn = state => state.seats.every(p =>
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
test("six freshly named players have never bought in", () => {
const state = emptyTable();
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
});
assert.equal(neverBoughtIn(state), true);
assert.equal(Betting.blindPreview(state), null, "and there is no hand to deal");
});
test("a table that has busted out is not the same thing", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
state.seats.forEach(p => { p.buyIns = 200; p.stack = 0; });
assert.equal(neverBoughtIn(state), false, "they had chips — they lost them");
});
test("one player still holding chips is not a never-bought-in table either", () => {
const state = emptyTable();
["Ann", "Ben"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
state.seats[0].stack = 50;
assert.equal(neverBoughtIn(state), false);
});
test("naming a starting stack is enough to deal", () => {
// The one field the message now points at. A seat with no stack of its own
// falls back to the table default, so setting it seats everybody's chips at
// once — which is why it's the right thing to send the dealer to.
const state = emptyTable();
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
assert.equal(Betting.blindPreview(state), null);
state.defaultStack = 200;
assert.ok(Betting.blindPreview(state), "one number and the table can play");
});
});