Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 1c2e716508 Ask the same two questions the same way everywhere
The round before this found the bank check and the standings reading a seat's own
stack where the felt reads the table's starting stack — two parts of the tool
disagreeing about how much money was on the table. That was worth a sweep, and
the sweep found more.

The colour-up panel had the same fault. It is the panel that says "count out
2×$100 for this player", and on a table opened the ordinary way — a starting
stack and some names — it showed nothing at all. The one table where every stack
is a round number made of the largest chips there are was the table it had
nothing to say about.

The other question, "is anybody in this chair", existed in four spellings: in the
seat count, in the seat menu twice, in the waiting list, and twice more in the
state module. Three of them looked at name, chips and cash; one also looked at a
hand being dealt. A chair that counts as occupied for one and empty for another
is how somebody's chips get given away — measured, a seat marked in hand with no
name and no stack yet was on offer to the next person in the waiting queue, on
top of whoever was already playing it.

Both questions now have one implementation and one name:

    "What is this seat holding?"   HoldemState.chipsHeldBy
    "Is anybody in this chair?"    HoldemState.seatInPlay

10 new tests, including one that pins the bank check, the standings and the
settle-up plan to the same total, and one that pins the seat count and the open
seats to the same set of empty chairs. The occupancy test was cut back to its old
three-part form on purpose to confirm the waiting-list case fails.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:05:32 -06:00
jheapsandClaude Opus 5 1689ecb4f0 Count the chips the table is actually playing with
"Starting stack" is how a night opens. The setup panel offers it and says what it
means — "seats without their own stack start here" — and everything a dealer
looks at reads it: the puck, the action bar, what a player is allowed to bet.

The money side didn't. The bank check and the standings read `seat.stack`
directly, which is null for exactly those seats. Measured on a three-handed table
with a $200 starting stack and nobody's own stack typed in: the felt played for
$600, the bank check reported no chips in play and pronounced itself balanced,
and the standings — the list a dealer settles the night from — listed nobody at
all. Three players, six hundred dollars, and the tool's own accounting said the
table was empty.

Both now ask the same question the felt asks. The check reports $600 in play
against nothing taken, which is the truth: those chips have not been paid for.
Empty chairs are excluded, or the starting stack would make every one of them
look like somebody holding two hundred dollars — a ten-seat table would have
opened with $2,000 of imaginary chips.

Being told is only half of it. The verdict names the seats rather than saying
"somebody's rebuy probably wasn't recorded", because the dealer's next move is to
go to a particular chair and the tool knows which. And the seats holding the
table's starting stack — the dealer's own instruction, said once in the setup
panel — can be written down in one tap instead of six trips through Add chips.
Only those: what somebody's forgotten rebuy was is a question only the dealer can
answer, and guessing it would be the tool inventing money, which is the fault
this round is about.

14 new tests, with the stack source and the empty-chair test broken on purpose to
confirm they fail. Walked through in the browser from a cleared table: set the
starting stack, name three seats, and the panel goes from "balanced, no chips" to
"$600 more in chips than cash taken. Ann, Ben and Cal are holding chips with no
buy-in written down" — then to balanced, with the buy-ins recorded and undoable.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:52:56 -06:00
jheapsandClaude Opus 5 55eb569d3b Keep the list of who's next, instead of leaving it on a napkin
A full home game always has two or three people standing behind the chairs, and
the list of who's next lives on a napkin or in the dealer's head. That is where
the argument an hour later comes from. The tool had just learned to open a chair
— somebody cashes out and the seat is free — and had nothing to say about who
should sit in it.

The Session panel keeps the list in order of arrival, which is the only ordering
anybody has ever agreed to. Whoever is at the front is offered the first open
chair by number, and everybody else waits their turn: a list that isn't kept in
order is a pile of names, and jumping the queue is the thing a dealer gets
shouted at for. With the table full it says so rather than offering a seat that
doesn't exist. The same offer appears on the empty chair's own menu — "Seat
Priya" — because tapping the chair somebody is about to sit in is what a dealer's
hand does anyway.

Sitting somebody down puts their name on the chair and nothing else. No stack, no
buy-in, not dealt in: they haven't paid yet, and inventing chips for them would
put money on the table nobody has handed over, which is the one thing the bank
check exists to catch. Add chips is the next thing the dealer does, and it is one
tap away in the same menu.

Two people called Mike are two people — the list is keyed by id, not by name.
Names are trimmed, blanks are refused, and a saved list from a table that never
had one comes back empty rather than undefined.

15 new tests, with the occupied-chair guard removed and a stack handed to the new
player on purpose to confirm they fail. Walked through the browser: two names
added to a full table, a player cashed out to open seat 2, the front of the queue
seated there with nothing in front of them, the bank still balanced, and the
second name taken off the list. 156 audits with the list populated, including a
name long enough to test the row.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:37:44 -06:00
9 changed files with 862 additions and 17 deletions
@@ -146,6 +146,14 @@
</div>
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
<h3 class="panelSubhead">Waiting list</h3>
<form id="waitingListForm" class="waitingAdd">
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
</form>
<ol id="waitingList" class="waitingList"></ol>
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
<div id="bankReconcile" class="bankReconcile" hidden>
<div class="bankReconcileRow">
<span>Cash taken</span>
@@ -168,6 +176,7 @@
<span id="bankPaidOut"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
</div>
<div id="settleUp" hidden>
@@ -2312,6 +2312,86 @@ body.hasActionBar #holdemSidebar {
font-style: italic;
}
/* The people standing behind the chairs. A short list in a panel that already has a
lot in it, so it is built from rows rather than a table: a name, the seat it can go
in, and a way off the list. */
.waitingAdd {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.waitingAdd input {
flex: 1;
min-width: 0;
min-height: 44px;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 8px;
color: #eee;
font-family: inherit;
/* 16px, or iOS zooms the whole page in when it takes focus. */
font-size: 16px;
padding: 0.35rem 0.6rem;
}
.waitingAdd .holdemBtn {
flex: none;
min-height: 44px;
}
.waitingList {
list-style: none;
counter-reset: waiting;
margin: 0 0 0.5rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.waitingRow {
display: flex;
align-items: center;
gap: 0.4rem;
counter-increment: waiting;
}
/* The position in the queue, which is the whole point of the list. */
.waitingRow::before {
content: counter(waiting) ".";
flex: none;
min-width: 1.2rem;
color: #9a9a9a;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.waitingName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #e8e8e8;
font-size: 0.85rem;
}
.waitingNote {
flex: none;
color: #9a9a9a;
font-size: 0.75rem;
}
.waitingRow .holdemBtn {
flex: none;
min-height: 44px;
}
.waitingDropBtn {
min-width: 44px;
}
/* ---------- Side-pot breakdown ---------- */
.potBreakdown {
+62 -6
View File
@@ -56,6 +56,35 @@ document.addEventListener("DOMContentLoaded", () => {
},
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
// The waiting list, from the Session panel and from an empty chair's menu.
// The state change goes inside runUndoable, not before it: the snapshot is
// taken as the first thing that happens, so anything done ahead of it is
// already baked into what Undo would restore.
seatWaiting: (id, index) => {
const entry = (state.waitingList || []).find(row => row.id === id);
const seat = state.seats[index];
if (!entry || !seat) {
Toast.show("They aren't on the list any more.", { duration: 3000 });
hooks.rerender();
return;
}
const occupied = HoldemState.seatInPlay(seat);
if (occupied) {
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
hooks.rerender();
return;
}
runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
() => HoldemState.seatFromWaitingList(state, id, index));
},
dropWaiting: id => {
const entry = (state.waitingList || []).find(row => row.id === id);
if (!entry) return;
runUndoable(`${entry.name} taken off the waiting list.`,
() => HoldemState.removeFromWaitingList(state, id));
},
onGiveButton: index => seatMenuHooks.makeDealer(index),
};
@@ -337,12 +366,11 @@ document.addEventListener("DOMContentLoaded", () => {
// in progress. Shrinking the table throws these away permanently, and it also
// orphans them in the session standings, where they keep their winnings but lose
// the stack and buy-in the settle-up is worked out from.
const seatHasSomeoneIn = player =>
!!(player.name && player.name.trim()) ||
player.inHand ||
(Number(player.stack) || 0) > 0 ||
(Number(player.buyIns) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0;
// One question, asked in one place. This predicate existed here, in the seat menu,
// in the action bar and twice in the state module, in four slightly different
// spellings — and a chair that counts as occupied for the waiting list but empty
// for the seat count is how somebody's chips get thrown away.
const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
document.getElementById("seatCountInput").addEventListener("change", async e => {
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
@@ -403,6 +431,34 @@ document.addEventListener("DOMContentLoaded", () => {
// A misdeal. Everything in the middle goes back to whoever put it there, and the
// same button deals the same hand again.
// The seats holding the table's starting stack, written down as cash taken.
document.getElementById("bankRecordBuyInsBtn").addEventListener("click", async () => {
const starting = HoldemState.seatsOwingCash(state).filter(row => row.fromStartingStack);
if (starting.length === 0) return;
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
const ok = await Dialog.confirm({
title: `Write down ${Money.format(total)} taken?`,
message: `${starting.map(row => `${row.name} ${Money.format(row.chips)}`).join(", ")} — the starting stack ` +
`each of them is playing with. Only do this if the cash is actually in the box.`,
confirmLabel: "Write it down",
});
if (!ok) return;
runUndoable(`${Money.format(total)} recorded from ${starting.length} ${starting.length === 1 ? "player" : "players"}.`,
() => HoldemState.recordStartingBuyIns(state));
});
document.getElementById("waitingListForm").addEventListener("submit", e => {
e.preventDefault();
const field = document.getElementById("waitingNameInput");
const name = field.value.trim();
if (!name) return;
field.value = "";
runUndoable(`${name} added to the waiting list.`, () => HoldemState.addToWaitingList(state, name));
field.focus();
});
document.getElementById("misdealBtn").addEventListener("click", async () => {
const inPlay = HoldemState.unresolvedContributions(state);
const acted = state.seats.filter(p => p.hasActed).length;
@@ -936,14 +936,20 @@ const Render = {
const list = document.getElementById("colourUpList");
if (!host || !list) return;
// What each player is holding, asked the same way the felt asks it — which
// includes the table's starting stack standing in for a seat that has never
// been given one. Reading `player.stack` here meant that a table opened the
// ordinary way, with "everyone starts with 200", showed an empty colour-up
// panel: nobody to count out, at the table where every single stack is a
// round number made of the largest chips there are.
const legend = this.state.chipLegend || [];
const rows = this.state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0)
.map(({ player, index }) => ({
.map((player, index) => ({ player, index, held: HoldemState.chipsHeldBy(this.state, player) }))
.filter(({ held }) => held > 0)
.map(({ player, index, held }) => ({
name: HoldemState.displayName(player, index),
stack: Betting.round(Number(player.stack) || 0),
chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend),
stack: held,
chips: Money.formatChipBreakdown(held, legend),
}))
.filter(row => row.chips);
@@ -1058,9 +1064,63 @@ const Render = {
this.renderBankReconciliation();
this.renderSettleUp(standings);
this.renderWaitingList();
this.renderStorageWarning();
},
// Who is standing behind the chairs, in the order they turned up. Each one carries
// the two things a dealer does with them: sit them down, or take them off the list
// because they've gone to the bar.
renderWaitingList() {
const list = document.getElementById("waitingList");
const hint = document.getElementById("waitingEmptyHint");
if (!list) return;
const waiting = this.state.waitingList || [];
const open = HoldemState.openSeats(this.state);
list.innerHTML = "";
hint.hidden = waiting.length > 0;
waiting.forEach((entry, position) => {
const row = document.createElement("li");
row.className = "waitingRow";
const name = document.createElement("span");
name.className = "waitingName";
name.textContent = entry.name;
row.appendChild(name);
// Only the person at the front is offered a seat, because a list nobody
// keeps in order is just a pile of names — and jumping the queue is the
// thing the dealer gets shouted at for.
if (position === 0 && open.length > 0) {
const seat = document.createElement("button");
seat.type = "button";
seat.className = "holdemBtn holdemBtnSmall waitingSeatBtn";
seat.textContent = `Seat ${open[0] + 1}`;
seat.title = `Sit ${entry.name} in seat ${open[0] + 1}`;
seat.addEventListener("click", () => this.hooks.seatWaiting(entry.id, open[0]));
row.appendChild(seat);
} else if (position === 0) {
const full = document.createElement("span");
full.className = "waitingNote";
full.textContent = "table full";
row.appendChild(full);
}
const drop = document.createElement("button");
drop.type = "button";
drop.className = "holdemBtn holdemBtnSmall holdemBtnGhost waitingDropBtn";
drop.textContent = "✕";
drop.setAttribute("aria-label", `Take ${entry.name} off the waiting list`);
drop.title = `Take ${entry.name} off the list`;
drop.addEventListener("click", () => this.hooks.dropWaiting(entry.id));
row.appendChild(drop);
list.appendChild(row);
});
},
// A toast is a moment; this table not being saved is a fact about the rest of the
// night. It sits at the top of the panel a dealer opens to check the money, and it
// stays there, because the raise pad dismisses toasts and the one time this warning
@@ -1210,14 +1270,43 @@ const Render = {
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
// Name them. "Somebody's rebuy wasn't recorded" is true and useless; the
// dealer's next move is to go to a particular seat, and the tool knows
// which. The commonest cause by far is the table's own starting stack:
// seats holding chips that were never bought, because "everyone starts
// with 200" is how a night opens.
const owing = HoldemState.seatsOwingCash(this.state);
const names = owing.map(row => row.name);
const who = names.length === 1
? names[0]
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
verdict.textContent = owing.length > 0
? `${Money.format(bank.difference)} more in chips than cash taken. ${who} ` +
`${owing.length === 1 ? "is" : "are"} holding chips with no buy-in written down.`
: `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
} else {
verdict.textContent =
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
}
verdict.classList.toggle("bankBalanced", bank.balanced);
verdict.classList.toggle("bankOff", !bank.balanced);
// One tap for the seats that are only holding the table's starting stack —
// which is the dealer's own instruction, said once in the setup panel, rather
// than the tool inventing cash. Anybody else's unrecorded rebuy is named in the
// sentence above and left to them, because only they know what it was.
const record = document.getElementById("bankRecordBuyInsBtn");
if (record) {
const starting = HoldemState.seatsOwingCash(this.state).filter(row => row.fromStartingStack);
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
record.hidden = starting.length === 0;
if (starting.length > 0) {
record.textContent = `Take ${Money.format(total)} from ${starting.length} ${starting.length === 1 ? "player" : "players"}`;
record.title = starting.length === 1
? `Write down ${Money.format(total)} taken from ${starting[0].name}`
: `Write down the starting stack as cash taken from ${starting.map(row => row.name).join(", ")}`;
}
}
},
// ---- Hand counter + history ----
@@ -47,10 +47,24 @@ const SeatMenu = {
run: () => this.hooks.toggleDealtIn(index),
});
// An empty chair with people waiting: the dealer taps the chair somebody is
// about to sit in, which is what their hand is doing anyway. Only the front of
// the queue is offered, for the same reason the panel only offers them — a list
// that isn't kept in order is just a pile of names.
const waiting = (this.state.waitingList || [])[0];
const emptyChair = !HoldemState.seatInPlay(player);
if (waiting && emptyChair) {
actions.push({
label: `Seat ${waiting.name}`,
className: "menuAccent",
run: () => this.hooks.seatWaiting(waiting.id, index),
});
}
// Leaving for the night, which is a different thing from sitting out a hand:
// the chips go back over the rail and the chair opens. Offered only for a seat
// that has somebody in it, since an empty chair has nobody to pay.
if ((player.name || "").trim() || Number(player.buyIns) > 0 || Number(player.stack) > 0) {
if (HoldemState.seatInPlay(player)) {
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
+134 -3
View File
@@ -115,6 +115,10 @@ const HoldemState = {
// out, and both halves have to stay on the record or the bank check reads
// as a shortfall for the rest of the evening.
cashedOut: [],
// People standing behind the chairs. A dealer at a full table keeps this
// list on a napkin; keeping it here means the tool can offer the next seat
// that opens to whoever has been waiting longest.
waitingList: [],
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
@@ -233,6 +237,16 @@ const HoldemState = {
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
parsed.waitingList = Array.isArray(parsed.waitingList)
? parsed.waitingList
.map(row => (typeof row === "string" ? { id: null, name: row } : row))
.filter(row => row && String(row.name || "").trim())
.map(row => ({
id: row.id || this.makeId("waiting"),
name: String(row.name).trim().slice(0, 24),
addedAt: Number(row.addedAt) || 0,
}))
: [];
parsed.cashedOut = Array.isArray(parsed.cashedOut)
? parsed.cashedOut
.filter(row => row && row.id)
@@ -789,7 +803,7 @@ const HoldemState = {
if (totals.has(player.id)) return;
const hasMoneyInPlay =
(Number(player.buyIns) || 0) > 0 ||
(Number(player.stack) || 0) > 0 ||
this.chipsHeldBy(state, player) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
(Number(player.committed) || 0) > 0;
if (!hasMoneyInPlay) return;
@@ -807,7 +821,7 @@ const HoldemState = {
// the way out. It is no longer on the table, so it is marked as paid
// rather than counted again at the end of the night.
const chipsNow = seat
? Money.round(Number(seat.stack) || 0)
? this.chipsHeldBy(state, seat)
: (gone ? gone.chips : 0);
// Chips already pushed into the hand in progress are still this
// player's money until it's won. Leaving them out makes the cash
@@ -875,6 +889,58 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
// ---- The people standing behind the chairs ----
//
// A full home game always has a couple of them, and the list lives on a napkin or
// in the dealer's head — which is where the argument comes from an hour later about
// who said they were next. Kept in order of arrival, because that is the only
// ordering anybody has ever agreed to.
addToWaitingList(state, name) {
const trimmed = String(name || "").trim().slice(0, 24);
if (!trimmed) return null;
if (!Array.isArray(state.waitingList)) state.waitingList = [];
const entry = { id: this.makeId("waiting"), name: trimmed, addedAt: Date.now() };
state.waitingList.push(entry);
return entry;
},
removeFromWaitingList(state, id) {
if (!Array.isArray(state.waitingList)) return null;
const index = state.waitingList.findIndex(row => row.id === id);
if (index < 0) return null;
return state.waitingList.splice(index, 1)[0];
},
// Chairs with nobody in them, in seat order. What "a seat has opened" means, and
// the same question the deal prompt answers when it counts the players.
openSeats(state) {
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => !this.seatInPlay(player))
.map(({ index }) => index);
},
// Sit the next person down. Their name goes on the chair and nothing else does:
// they haven't bought in yet, and guessing a stack for them would put chips on the
// table that nobody has paid for — which is the one thing the bank check exists to
// catch.
seatFromWaitingList(state, id, index) {
const seat = state.seats[index];
if (!seat) return { ok: false, reason: "There's no such seat." };
if (this.seatInPlay(seat)) {
return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
}
const entry = this.removeFromWaitingList(state, id);
if (!entry) return { ok: false, reason: "They aren't on the list any more." };
seat.name = entry.name;
// Not dealt in and holding nothing: the next thing that happens is the dealer
// taking their money, and Add chips is what says so.
seat.inHand = false;
return { ok: true, name: entry.name, index };
},
// ---- Somebody leaving with their chips ----
//
// The most ordinary thing that happens at a home game after midnight, and the tool
@@ -1014,6 +1080,71 @@ const HoldemState = {
};
},
// Is anybody actually in this chair? A seat with a name on it, chips in front of
// it, cash against it or a hand dealt to it. Empty chairs are not players, which
// matters the moment the table's starting stack starts standing in for a seat that
// has never been given one — every empty chair would otherwise look like somebody
// holding two hundred dollars.
seatInPlay(player) {
if (!player) return false;
return !!(player.name || "").trim() ||
(Number(player.buyIns) || 0) > 0 ||
(Number(player.stack) || 0) > 0 ||
(Number(player.committed) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
player.inHand === true;
},
// What a seat is holding, the way the felt and the betting see it — which includes
// the table's starting stack standing in for a seat that has never been given one.
//
// This is the number the money side of the tool was missing. "Starting stack" is
// offered in the setup panel as the ordinary way to open a table, and everything a
// dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
// check and the standings read `seat.stack` directly instead, which is null for
// exactly those seats: measured on a three-handed table with a $200 starting stack
// and nobody's own stack set, the felt played for $600 while the bank check said
// "no chips in play, balanced" and the standings listed nobody at all.
chipsHeldBy(state, player) {
if (!this.seatInPlay(player)) return 0;
return Math.max(0, Money.round(Betting.stackOf(state, player)));
},
// Seats playing with chips the box has no cash against.
//
// Two kinds, and they are not the same problem. A seat holding the table's starting
// stack has never been given chips of its own — the dealer set "everyone starts
// with 200" and sat people down, which is the ordinary way to open a table and the
// reason this whole gap existed. A seat with its own stack and no buy-in is a
// rebuy somebody forgot to write down, and only the dealer knows what it was.
seatsOwingCash(state) {
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
!((Number(player.buyIns) || 0) > 0))
.map(({ player, index }) => ({
index,
name: this.displayName(player, index),
chips: this.chipsHeldBy(state, player),
// True when they are holding the table's starting stack rather than one
// the dealer typed for them.
fromStartingStack: player.stack === null || player.stack === undefined ||
String(player.stack).trim() === "",
}));
},
// Write down the cash for the seats that are holding the table's starting stack.
// Only those: what somebody else's unrecorded rebuy was is a question only the
// dealer can answer, and guessing it would be the tool inventing money.
recordStartingBuyIns(state) {
const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
return {
seats: owed.length,
total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
};
},
bankReconciliation(state) {
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
// Cash the box took from people who have since left counts as taken: it is in
@@ -1024,7 +1155,7 @@ const HoldemState = {
const chipsInPlay = state.seats.reduce(
(sum, p) =>
sum +
Math.max(0, Number(p.stack) || 0) +
this.chipsHeldBy(state, p) +
Math.max(0, Number(p.committed) || 0) +
Math.max(0, Number(p.amountOverride) || 0),
0
+133
View File
@@ -0,0 +1,133 @@
// One question, one answer.
//
// The round before this found the bank check and the standings reading `seat.stack`
// where the felt reads the table's starting stack — two parts of the tool disagreeing
// about how much money is on the table. This is the sweep that followed: everywhere
// else the same two questions get asked, asked the same way.
//
// "What is this seat holding?" -> HoldemState.chipsHeldBy
// "Is anybody in this chair?" -> HoldemState.seatInPlay
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, SettleUp } = require("./load");
const openedOnTheDefault = (stack = 200) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 6);
state.defaultStack = stack;
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = stack;
});
return state;
};
describe("what a seat is holding", () => {
test("is the same number the felt plays with", () => {
const state = openedOnTheDefault();
state.seats.forEach((player, i) => {
if (i > 2) return;
assert.equal(HoldemState.chipsHeldBy(state, player), Betting.stackOf(state, player));
});
});
test("and is nothing at all for an empty chair", () => {
const state = openedOnTheDefault();
// Betting's own answer for an empty chair is the table default, because it is
// only ever asked about seats that are in a hand. The money side has to ask
// about all ten chairs, so it checks whether anybody is in them first.
assert.equal(Betting.stackOf(state, state.seats[5]), 200);
assert.equal(HoldemState.chipsHeldBy(state, state.seats[5]), 0);
});
test("counts a stack the dealer typed over the table's default", () => {
const state = openedOnTheDefault();
state.seats[0].stack = 75;
assert.equal(HoldemState.chipsHeldBy(state, state.seats[0]), 75);
});
});
describe("is anybody in this chair", () => {
const empty = () => HoldemState.createDefault().seats[0];
test("an untouched chair is empty", () => {
assert.equal(HoldemState.seatInPlay(empty()), false);
assert.equal(HoldemState.seatInPlay(null), false);
});
test("a name, chips, cash, a bet or a dealt hand all count", () => {
[
["a name", p => { p.name = "Ann"; }],
["chips", p => { p.stack = 50; }],
["cash taken", p => { p.buyIns = 100; }],
["a bet in front of them", p => { p.committed = 5; }],
["money already swept in", p => { p.amountOverride = 20; }],
["a hand dealt to them", p => { p.inHand = true; }],
].forEach(([what, mark]) => {
const seat = empty();
mark(seat);
assert.equal(HoldemState.seatInPlay(seat), true, what);
});
});
test("whitespace is not a name", () => {
const seat = empty();
seat.name = " ";
assert.equal(HoldemState.seatInPlay(seat), false);
});
});
describe("the parts that used to disagree", () => {
test("the open seats and the seat count see the same empty chairs", () => {
const state = openedOnTheDefault();
// Three players, six chairs.
assert.deepEqual(HoldemState.openSeats(state), [3, 4, 5]);
const occupied = state.seats.filter(p => HoldemState.seatInPlay(p)).length;
assert.equal(occupied + HoldemState.openSeats(state).length, state.seats.length);
});
test("a chair holding only a dealt hand is not offered to the waiting list", () => {
// The old test looked at name, buy-ins and stack. A seat marked in-hand with
// none of those would have been handed to the next person in the queue, on top
// of whoever was already playing it.
const state = openedOnTheDefault();
state.seats[4].inHand = true;
assert.deepEqual(HoldemState.openSeats(state), [3, 5]);
HoldemState.addToWaitingList(state, "Priya");
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 4);
assert.equal(result.ok, false);
});
test("the bank, the standings and the settle-up all price the night the same way", () => {
const state = openedOnTheDefault();
state.seats[0].stack = 320; // Ann is up
state.seats[1].stack = 80; // Ben is down
const bank = HoldemState.bankReconciliation(state);
const standings = HoldemState.sessionStandings(state);
const chipsFromStandings = standings
.filter(row => !row.cashedOut)
.reduce((sum, row) => sum + row.chipsInFront, 0);
assert.equal(bank.chipsInPlay, chipsFromStandings, "same chips on the table");
assert.equal(bank.balanced, true);
// And the payments the dealer is told to make add up to the same money.
const plan = SettleUp.plan(standings);
const owed = standings.reduce((sum, row) => sum + Math.max(0, row.cashPosition), 0);
const paid = plan.payments.reduce((sum, row) => sum + row.amount, 0);
assert.equal(paid + (plan.shortBy || 0) - (plan.overBy || 0), owed);
});
test("a player still holding the table's starting stack is in all three", () => {
const state = openedOnTheDefault();
const standings = HoldemState.sessionStandings(state);
assert.equal(standings.length, 3);
assert.deepEqual(standings.map(row => row.chipsInFront), [200, 200, 200]);
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
assert.deepEqual(SettleUp.plan(standings).payments, [], "everyone is even, so nobody pays anybody");
});
});
+166
View File
@@ -0,0 +1,166 @@
// The chips the felt plays with are the chips the box is checked against.
//
// "Starting stack" is offered in the setup panel as the ordinary way to open a table —
// "seats without their own stack start here" — and everything a dealer looks at reads
// it: the puck, the action bar, what a player is allowed to bet. The money side did
// not. It read `seat.stack` directly, which is null for exactly those seats, so a
// three-handed table playing for $600 reported no chips in play, a balanced bank and a
// standings list with nobody on it.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, Betting } = require("./load");
const openedWithStartingStack = (stack = 200, names = ["Ann", "Ben", "Cal"]) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 6);
state.defaultStack = stack;
names.forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
});
return state;
};
describe("what the table is playing for", () => {
test("is what the bank counts", () => {
const state = openedWithStartingStack();
// What the felt says each of them has.
assert.deepEqual(state.seats.slice(0, 3).map(p => Betting.stackOf(state, p)), [200, 200, 200]);
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
});
test("and the box being empty is a problem it now reports", () => {
const bank = HoldemState.bankReconciliation(openedWithStartingStack());
assert.equal(bank.cashIn, 0);
assert.equal(bank.difference, 600);
assert.equal(bank.balanced, false, "six hundred dollars of chips nobody paid for");
});
test("empty chairs are not people holding two hundred dollars", () => {
// The starting stack stands in for a seat that hasn't been given one, and every
// empty chair is such a seat. Counting them would invent $600 of chips at an
// empty table.
const state = openedWithStartingStack();
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600, "three players, not six chairs");
const empty = HoldemState.createDefault();
empty.defaultStack = 200;
assert.equal(HoldemState.bankReconciliation(empty).chipsInPlay, 0);
assert.equal(HoldemState.bankReconciliation(empty).balanced, true);
});
test("once the cash is written down it balances", () => {
const state = openedWithStartingStack();
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("a table where everyone has their own stack is unchanged", () => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 4);
["Ann", "Ben"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].stack = 150;
state.seats[i].buyIns = 150;
});
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.chipsInPlay, 300);
assert.equal(bank.balanced, true);
});
});
describe("who is on the settle-up list", () => {
test("everyone holding chips, including the ones holding the table's own stack", () => {
const rows = HoldemState.sessionStandings(openedWithStartingStack());
assert.deepEqual(rows.map(r => r.name), ["Ann", "Ben", "Cal"]);
assert.deepEqual(rows.map(r => r.chipsInFront), [200, 200, 200]);
});
test("with nothing bought in yet, which is the thing to notice", () => {
const rows = HoldemState.sessionStandings(openedWithStartingStack());
assert.deepEqual(rows.map(r => r.boughtIn), [0, 0, 0]);
assert.deepEqual(rows.map(r => r.cashPosition), [200, 200, 200]);
});
});
describe("naming the seats the cash is missing for", () => {
test("says who, and which kind of missing it is", () => {
const state = openedWithStartingStack();
state.seats[2].stack = 320; // Cal typed his own: an unrecorded rebuy
const owing = HoldemState.seatsOwingCash(state);
assert.deepEqual(owing.map(row => row.name), ["Ann", "Ben", "Cal"]);
assert.deepEqual(owing.map(row => row.fromStartingStack), [true, true, false]);
assert.deepEqual(owing.map(row => row.chips), [200, 200, 320]);
});
test("a seat whose buy-in is written down isn't on the list", () => {
const state = openedWithStartingStack();
state.seats[0].buyIns = 200;
assert.deepEqual(HoldemState.seatsOwingCash(state).map(row => row.name), ["Ben", "Cal"]);
});
test("and a table where nothing is owed has nobody on it", () => {
const state = openedWithStartingStack();
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
assert.deepEqual(HoldemState.seatsOwingCash(state), []);
});
});
describe("writing the starting stacks down", () => {
test("records exactly what those seats are holding", () => {
const state = openedWithStartingStack();
const done = HoldemState.recordStartingBuyIns(state);
assert.equal(done.seats, 3);
assert.equal(done.total, 600);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("and leaves somebody else's forgotten rebuy alone", () => {
// Only the dealer knows what that was. Guessing would be the tool inventing
// money, which is the fault this whole round is about.
const state = openedWithStartingStack();
state.seats[2].stack = 320;
HoldemState.recordStartingBuyIns(state);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 0]);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, 320, "still exactly Cal's chips, and still reported");
});
test("does nothing on a table that doesn't need it", () => {
const state = openedWithStartingStack();
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
const done = HoldemState.recordStartingBuyIns(state);
assert.equal(done.seats, 0);
assert.equal(done.total, 0);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
});
});
describe("through a hand", () => {
test("the money adds up from the deal to the payout", () => {
const state = openedWithStartingStack(200, ["Ann", "Ben", "Cal", "Dee"]);
HoldemState.recordStartingBuyIns(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.beginHand(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "blinds are still on the table");
[3, 0, 1].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
const pot = Betting.potSoFar(state);
Betting.sweepToHandTotals(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
HoldemState.startNextHand(state);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, true);
assert.equal(bank.chipsInPlay, 800, "four buy-ins of two hundred, wherever they have moved to");
assert.ok(state.seats[0].stack > 200, "and the winner is holding more than they sat down with");
});
});
+167
View File
@@ -0,0 +1,167 @@
// The people standing behind the chairs.
//
// A full home game always has a couple of them, and the list lives on a napkin or in
// the dealer's head — which is where the argument comes from an hour later about who
// said they were next. Order of arrival, because it is the only ordering anybody has
// ever agreed to.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState } = require("./load");
const table = (seated = 2, seats = 4) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, seats);
["Ann", "Ben", "Cal", "Dee"].slice(0, seated).forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].stack = 200;
state.seats[i].buyIns = 200;
});
return state;
};
describe("keeping the list", () => {
test("names go on in the order they arrive", () => {
const state = table();
HoldemState.addToWaitingList(state, "Priya");
HoldemState.addToWaitingList(state, "Sam");
assert.deepEqual(state.waitingList.map(row => row.name), ["Priya", "Sam"]);
});
test("whitespace is trimmed and nothing empty gets on", () => {
const state = table();
assert.ok(HoldemState.addToWaitingList(state, " Priya "));
assert.equal(HoldemState.addToWaitingList(state, " "), null);
assert.equal(HoldemState.addToWaitingList(state, ""), null);
assert.deepEqual(state.waitingList.map(row => row.name), ["Priya"]);
});
test("two people with the same name are two people", () => {
// Home games have two Mikes. The list is keyed by id, not by name.
const state = table();
HoldemState.addToWaitingList(state, "Mike");
HoldemState.addToWaitingList(state, "Mike");
assert.equal(state.waitingList.length, 2);
assert.notEqual(state.waitingList[0].id, state.waitingList[1].id);
HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
assert.equal(state.waitingList.length, 1, "only one of them leaves");
});
test("taking somebody off gives them back, so the toast can name them", () => {
const state = table();
HoldemState.addToWaitingList(state, "Priya");
const gone = HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
assert.equal(gone.name, "Priya");
assert.deepEqual(state.waitingList, []);
assert.equal(HoldemState.removeFromWaitingList(state, "not-a-real-id"), null);
});
});
describe("which chairs are open", () => {
test("the ones with nobody in them, in seat order", () => {
assert.deepEqual(HoldemState.openSeats(table(2, 4)), [2, 3]);
assert.deepEqual(HoldemState.openSeats(table(4, 4)), []);
});
test("a chair with chips or a buy-in on it is not open", () => {
// Somebody who has stepped away from the table still owns their seat.
const state = table(2, 4);
state.seats[2].stack = 120;
state.seats[3].buyIns = 100;
assert.deepEqual(HoldemState.openSeats(state), []);
});
});
describe("sitting somebody down", () => {
test("their name goes on the chair and they come off the list", () => {
const state = table();
HoldemState.addToWaitingList(state, "Priya");
HoldemState.addToWaitingList(state, "Sam");
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
assert.equal(result.ok, true);
assert.equal(state.seats[2].name, "Priya");
assert.deepEqual(state.waitingList.map(row => row.name), ["Sam"]);
});
test("with nothing in front of them, because they haven't bought in yet", () => {
// Guessing a stack would put chips on the table nobody has paid for, which is
// the one thing the bank check exists to catch.
const state = table();
HoldemState.addToWaitingList(state, "Priya");
HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
assert.equal(state.seats[2].stack, null);
assert.equal(state.seats[2].buyIns, 0);
assert.equal(state.seats[2].inHand, false, "they're in the next hand, not this one");
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("never into a chair somebody is already in", () => {
const state = table();
HoldemState.addToWaitingList(state, "Priya");
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 0);
assert.equal(result.ok, false);
assert.match(result.reason, /Ann is already/);
assert.equal(state.waitingList.length, 1, "and they keep their place in the queue");
});
test("nor from a name that has already been seated", () => {
const state = table();
HoldemState.addToWaitingList(state, "Priya");
const id = state.waitingList[0].id;
HoldemState.seatFromWaitingList(state, id, 2);
const again = HoldemState.seatFromWaitingList(state, id, 3);
assert.equal(again.ok, false);
assert.equal(state.seats[3].name, "", "the second chair is untouched");
});
test("a seat that doesn't exist is refused rather than invented", () => {
const state = table();
HoldemState.addToWaitingList(state, "Priya");
assert.equal(HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 99).ok, false);
assert.equal(state.waitingList.length, 1);
});
});
describe("a seat opening up", () => {
test("cashing somebody out is what makes room for the next one", () => {
const state = table(4, 4);
HoldemState.addToWaitingList(state, "Priya");
assert.deepEqual(HoldemState.openSeats(state), [], "the table is full");
HoldemState.cashOut(state, 1);
assert.deepEqual(HoldemState.openSeats(state), [1]);
const seated = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 1);
assert.equal(seated.ok, true);
assert.equal(state.seats[1].name, "Priya");
// Ben's night is still on the record, and the money still adds up.
assert.equal(state.cashedOut.length, 1);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
describe("what a saved table remembers", () => {
test("the list survives a reload", () => {
const state = table();
HoldemState.addToWaitingList(state, "Priya");
const migrated = HoldemState.migrate(JSON.parse(JSON.stringify(state)));
assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya"]);
});
test("a table saved before the list existed has nobody waiting", () => {
const parsed = JSON.parse(JSON.stringify(table()));
delete parsed.waitingList;
assert.deepEqual(HoldemState.migrate(parsed).waitingList, []);
});
test("junk in a saved list is cleaned up rather than trusted", () => {
const parsed = JSON.parse(JSON.stringify(table()));
parsed.waitingList = [{ name: " Priya " }, { name: "" }, null, "Sam"];
const migrated = HoldemState.migrate(parsed);
assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya", "Sam"]);
assert.ok(migrated.waitingList.every(row => row.id), "and everybody gets an id");
});
});