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
7 changed files with 174 additions and 14 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;
}
@@ -593,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."
@@ -605,6 +614,26 @@ 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
@@ -818,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`);
});
});
});
+47
View File
@@ -132,3 +132,50 @@ describe("the way back from a table where everybody is sat out", () => {
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");
});
});