Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 4db6f6e4d3 Take the chips the pot was priced with, not just pay them out
Two rounds of asking where the tool disagrees with itself, and here is the last
of it — the one that was actually creating money.

There are two ways a contribution gets in. Tapping the action moves chips: the
bet leaves the stack, sits in front of the player, and is swept into their hand
total. The other is the "without live betting" panel — a base pot amount, or
chips counted out on a seat — for a dealer recording a hand they are watching
rather than driving. Nothing leaves anybody's stack there.

The pot wizard prices both the same way, and awarding it credits the winner. So
in the second mode the table simply grew. Measured at a three-handed table with a
$50 base pot: stacks 200, 200, 200 before, pot priced at $150, and afterwards
350, 200, 200 — six hundred dollars of chips became seven hundred and fifty, and
the bank check, newly able to see it, blamed a rebuy nobody had made.

So before the pot is pushed, whatever was assumed rather than taken comes off the
stacks that assumed it, and is recorded where a swept bet lives. Chips already
committed or swept are left alone — they have been paid once, and taking them
twice is the same bug the other way round. A base pot on top of a live bet
collects only the difference. Nobody is charged more than they are holding, and
an empty chair or a seat sitting out is charged nothing at all.

The same hand now ends 300, 150, 150: the table has the six hundred it started
with, and the bank check is balanced before, during and after.

13 new tests, with the already-paid test disabled on purpose to confirm five of
them catch the double charge. Walked through the UI in base-pot mode: pot badge
$150, wizard pricing three seats at $50 each, and afterwards the felt holding
exactly what it held before.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:21:19 -06:00
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
jheapsandClaude Opus 5 86886ac126 Take a hand back, which the rulings sheet has been asking for all along
This tool has told dealers to redeal on a misdeal since the rulings sheet was
written — first card to the wrong seat, a seat entitled to a hand dealt out, one
of the first two cards exposed — and it had no way to do it. The only control
that ended a live hand was Next Hand, which says out loud that it takes the money
off the table without paying anybody. That is the right answer for an abandoned
pot and the wrong one for a misdeal, where the blinds and the calls belong to the
people who put them in.

Undo covers the first second of it: "↶ dealing the hand" is one tap. But the
action history lives in memory, so a dealer who is three calls in, or who has
refreshed the page mid-hand, has nothing — and a refresh mid-hand is exactly the
situation this tool has spent rounds making survivable everywhere else.

Misdeal appears in the header while a hand is out and goes away when it isn't:
before the deal there is nothing to take back, and afterwards it would be
offering to unwind a hand that has already been paid. It says what it will do to
the money — "$25 goes back to the players who put it in, cards are cleared, and
the same button deals again" — and adds a note when two or more players have
already acted, because most rooms let a deal stand at that point. It doesn't
refuse: which conditions make a misdeal is the dealer's call and the sheet is
right there.

What comes back is everything, including chips already swept into hand totals on
an earlier street — the first version returned only the current street's and
would have quietly kept a flop's betting. What stays is the button, the blinds,
the straddle and anyone posting in: a misdeal is the same hand dealt again, not
the next one, so the hand number doesn't move and nothing is logged.

13 new tests, with the sweep and the straddle broken on purpose to confirm they
catch it. Measured through the UI: pot $25 with three players in, take it back,
every stack is exactly $200 again, the bank still balances, and dealing again
posts the same blinds on the same players from the same button. 156 audits with
the button live, including a landscape header that stays one row at 46px.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:23:27 -06:00
jheapsandClaude Opus 5 f966d8c340 Pay somebody out and keep the chair, the cash and the record straight
The most ordinary thing that happens at a home game after midnight — somebody
counts up and goes home — and the tool had nothing for it. Freeing the chair
meant wiping the seat by hand, and the bank check then declared their whole stack
missing: "someone has paid for chips they haven't been given", for the rest of
the night. Measured on a balanced table, cashing a player out with $350 in front
of them left the check $350 short. Wiping their buy-in as well silenced it, at
the price of losing their entire night from the standings and from the file the
dealer hands over at the end.

Cashing out is two facts, and both have to survive the empty chair: the box took
their cash, and the box handed their chips back. Both are kept now, so the chair
opens and the money still adds up.

Cash out… is in the seat menu. It says what to count — "$350 — 3×$100, 2×$25" —
what they bought in for, and whether they leave up or down, because that is the
sentence the dealer says while their hands are moving. The bank check gains a
Paid out line and reads "balanced ... once the $350 paid out to players who have
left is counted", alongside the discards and the house's cut. The standings keep
them with a stripe and the words "· paid out", so nobody counts money out to them
twice at 2am, and the export marks them the same way.

It refuses to pay anybody with money still in the middle: taking chips out of a
pot other people can win, or handing the same chips over twice, are both worse
than waiting for the hand to finish. The chair gets a fresh id, because the
person who had it has gone and the next player to sit down is not them.

Two things this turned up. The first draft worked out what a seat was holding by
itself, read `Number(null)` as a perfectly good zero, and offered to pay a player
with no stack set nothing at all — it asks Betting now, which owns that rule.
The second was mine and the audit caught it: marking the departed row with
opacity took the In and Won cells to 3.83:1, under AA, on the very list a dealer
settles from. It is a stripe instead.

19 new tests, with the departed buy-in dropped from the check and the pot guard
disabled on purpose to confirm they fail. Walked through in the browser: menu,
confirm, payout, bank, standings, undo, and the refusal.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:08:46 -06:00
jheapsandClaude Opus 5 0e2821257f Run the clock on a player, instead of counting in your head
The bar has had a stopwatch on the acting seat for a while: how long this player
has been thinking, so the dealer knows when it's worth a nudge. What it never had
was the thing a dealer actually does about it. Calling the clock is a procedure —
a player asks, the dealer grants it, the player gets a set time and then a
countdown said out loud, and if they still haven't acted the hand is decided for
them. Running that from this tool meant counting in your head while the table
argued about whether it had been thirty seconds yet.

Thirty and then ten, which is the common form and what the TDA writes down. The
countdown is the part the table hears, so it is marked apart rather than being
ten more seconds of the same: the readout switches to a bare number and turns
red. At zero the bar says the ruling in the dealer's own words and offers it as
one button.

The ruling depends on what they were facing, and getting that backwards is the
part worth being careful about. Facing a bet, the hand is dead. Facing nothing,
it is checked — nobody gets folded for being slow when checking was free.

Three things about where it sits. The button appears only once somebody has been
thinking long enough for the table to start asking, which is the same threshold
the stopwatch already marks, so an ordinary hand costs no room at all: measured
at 390x844, the bar is 152px with or without it. It is 44px like everything else
a thumb has to find — it was drawn at 36 and the audit called it at every touch
viewport the moment the check ran, which is the rule doing its job on a control
that kills a hand. And once the count is over the Stop button goes, because there
is nothing left to stop: at 640x360 its 94px pushed the undo control onto a
second line and the taller bar covered the two bottom seats.

The ordinary Fold/Check/Raise stay underneath the ruling. A player who speaks up
as the count ends is still acting, and whether it was in time is the dealer's
call, not the tool's.

11 new tests, and both halves of the ruling were inverted on purpose to check
they catch it. Watched end to end in the browser at shortened intervals: the
button appears, the count runs down, the ruling lands, and the hand is killed —
or checked — with the undo control naming what happened.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 05:40:06 -06:00
16 changed files with 2122 additions and 22 deletions
@@ -14,6 +14,7 @@
</div>
<div id="holdemHeaderActions">
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
@@ -145,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>
@@ -162,7 +171,12 @@
<span>House take</span>
<span id="bankRaked"></span>
</div>
<div class="bankReconcileRow" id="bankPaidOutRow" hidden>
<span>Paid out</span>
<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>
@@ -391,6 +405,23 @@
</p>
</section>
<section class="ruling" data-short="Clock">
<h3>Calling the clock on a player</h3>
<p>
Any player may ask for a clock once a decision is taking unreasonably
long; it is the dealer, not the table, who grants it. The player then
gets a set time and a countdown out loud — thirty seconds and ten is the
usual form.
</p>
<p>
If the count reaches zero with no action, the hand is <em>dead</em> if
they were facing a bet, and <em>checked</em> if they were not: nobody is
folded for slowness when checking was free. The action bar runs the
count once you tap <em>Call the clock</em>, and offers the ruling as one
button when it ends.
</p>
</section>
<section class="ruling" data-short="Straddle">
<h3>A straddle is a blind raise</h3>
<p>
+146
View File
@@ -2254,6 +2254,144 @@ body.hasActionBar #holdemSidebar {
font-weight: 600;
}
/* Starting and stopping a clock. Quiet, because it only appears once somebody has
been thinking long enough for the table to ask.
44px like everything else a thumb has to find. It was drawn at 36 to keep the status
line short, and the audit called it at every touch viewport the moment it ran — which
is the rule doing its job: this control kills a hand, and the argument for shaving it
was that the row it sits in is made of text. */
.actionClockBtn {
flex: none;
min-height: 44px;
padding: 0.25rem 0.6rem;
border-radius: 8px;
border: 1px solid #5b4a2a;
background: rgba(216, 180, 106, 0.12);
color: #e8cf9a;
font-family: inherit;
font-size: 0.72rem;
cursor: pointer;
}
/* The count has run out. Said in the dealer's own words, above the button that
carries it out — and coloured like the thing it is, which is a hand about to be
killed for slow play. */
.clockExpired {
margin: 0 0 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
background: rgba(224, 123, 123, 0.12);
border: 1px solid rgba(224, 123, 123, 0.45);
color: #f3c9c9;
font-size: 0.8rem;
}
.actionBtn.actionEnforceClock {
background: linear-gradient(180deg, #6b2b2b, #4a1d1d);
border-color: #8c3a3a;
color: #ffe2e2;
font-weight: 600;
}
/* A player who has taken their chips and gone. Still on the list, because the box took
their cash and the night's record has to say so, but set apart so nobody counts money
out to them twice at 2am.
Set apart by a stripe rather than by fading the row, which is what this first did:
at 0.72 the contrast check measured the In and Won cells at 3.83:1, under AA, on the
list a dealer settles the night from. The row says "paid out" in words next to the
name — it doesn't need to be hard to read as well. */
.standingsRow.standingsGone {
background: rgba(255, 255, 255, 0.04);
border-left: 2px solid #4a4a4a;
padding-left: 0.35rem;
}
.standingsRow.standingsGone .standingsName {
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 {
@@ -3431,6 +3569,14 @@ body.hasActionBar #toastHost {
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
}
/* The clock's ruling, said in a sentence, is a line this screen cannot pay for:
measured at 640x360, the bar goes from 152px to 269px when the count runs out and
the bottom seats end up behind it. The button carries the same ruling in three
words — "Kill Dee's hand" — so the sentence is what goes, not the call. */
.clockExpired {
display: none;
}
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
.seat {
width: 62px;
@@ -194,6 +194,32 @@ const ActionBar = {
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
row.appendChild(tank);
// Rendered every time and shown by the tick once the wait is long enough
// to be worth it, so a clock never costs a re-render at the moment the
// dealer reaches for it — and never occupies the bar in the ordinary case
// where nobody is holding anybody up.
const clock = document.createElement("button");
clock.type = "button";
clock.id = "actionClockBtn";
clock.className = "actionClockBtn";
// Once the count is over there is nothing to stop: the ruling has been
// made, and the dealer either carries it out or takes an ordinary action,
// both of which end the clock themselves. Leaving the button there also
// cost real room — at 640x360 its 94px pushed the undo control onto a
// second line, and the taller bar covered the two bottom seats.
const expiredNow = this.clockRunning() && this.clockLeft() === 0;
clock.hidden = !this.clockRunning() || expiredNow;
if (this.clockRunning()) {
clock.textContent = "Stop the clock";
clock.title = "Put the decision back in the player's own time";
clock.addEventListener("click", () => { this.stopClock(); this.render(); });
} else {
clock.textContent = "Call the clock";
clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`;
clock.addEventListener("click", () => this.callClock());
}
row.appendChild(clock);
}
// A standing control rather than a toast per action: a dealer taps this bar
@@ -233,6 +259,74 @@ const ActionBar = {
NUDGE_AFTER_S: 30,
SLOW_AFTER_S: 60,
// ---- Calling the clock ----
//
// The stopwatch above says how long somebody has been thinking. This is what a
// dealer does about it, and it is a procedure with a shape: a player asks for a
// clock, the dealer grants it, the player gets a set time and then a countdown,
// and if they still haven't acted the hand is decided for them — dead if they were
// facing a bet, checked if they weren't.
//
// The tool knew none of that. A dealer running it had to count in their head while
// the table argued about whether it had been thirty seconds, which is the moment
// the count needs to be somewhere everybody can see.
//
// Thirty seconds and then a ten-second countdown is the common form of the rule and
// what the TDA writes down. The countdown is the part the table hears out loud, so
// it is marked separately rather than being ten more seconds of the same.
CLOCK_THINK_S: 30,
CLOCK_COUNTDOWN_S: 10,
clockSeatIndex: null,
clockStartedAt: null,
clockRunning() {
return this.clockSeatIndex !== null &&
this.clockSeatIndex === this.state.actingSeatIndex &&
this.clockStartedAt !== null;
},
// Seconds left before the hand is decided, or null when no clock is running.
// Negative is impossible: it stops at zero and stays there until the dealer acts.
clockLeft(now = Date.now()) {
if (!this.clockRunning()) return null;
const total = this.CLOCK_THINK_S + this.CLOCK_COUNTDOWN_S;
return Math.max(0, total - Math.floor((now - this.clockStartedAt) / 1000));
},
// What happens when it reaches zero, in the words the dealer says out loud.
// Facing a bet, the hand is dead. Facing none, it is checked — a player who could
// have checked for free is not folded for being slow.
clockRuling() {
const index = this.state.actingSeatIndex;
if (index === null || index === undefined) return null;
const player = this.state.seats[index];
const owed = Betting.toCall(this.state, player);
const who = HoldemState.displayName(player, index);
return owed > 0
? { index, who, verb: "fold", label: `Time — ${who}'s hand is dead`, say: `${who} is facing ${Money.format(owed)}, so the hand is dead.` }
: { index, who, verb: "check", label: `Time — check for ${who}`, say: `${who} had nothing to call, so the hand is checked.` };
},
callClock() {
const index = this.state.actingSeatIndex;
if (index === null || index === undefined) return;
this.clockSeatIndex = index;
this.clockStartedAt = Date.now();
this.clockExpiredShown = false;
this.render();
},
stopClock() {
this.clockSeatIndex = null;
this.clockStartedAt = null;
this.clockExpiredShown = false;
},
// Whether the bar has already been rebuilt into its "time is up" shape, so the
// one-second tick doesn't rebuild it again every second afterwards.
clockExpiredShown: false,
tankSeatIndex: null,
tankStartedAt: null,
tankInterval: null,
@@ -262,6 +356,11 @@ const ActionBar = {
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
// A clock belongs to one player's decision. The moment the action moves —
// because they acted, or because the dealer took something back — it is
// over, and leaving it running would put the next player on a countdown
// nobody called on them.
this.stopClock();
}
if (!this.tankInterval) {
@@ -283,6 +382,36 @@ const ActionBar = {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
// A clock takes the readout over: the number the table is waiting on is no
// longer how long this has taken but how long is left. At zero the bar has to
// change shape — a ruling and the button that carries it out — so that one
// gets a full render, once, rather than being painted in by the tick.
const left = this.clockLeft();
if (left !== null) {
if (left === 0 && !this.clockExpiredShown) {
this.clockExpiredShown = true;
this.render();
return;
}
el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
el.title = "Time left before the hand is decided";
el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
return;
}
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking — which is the same threshold the
// stopwatch already marks. Before that it is a control nobody needs, and this
// bar is docked over the seats.
const nudge = document.getElementById("actionClockBtn");
if (nudge && !this.clockRunning()) {
const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
? false
: waited < this.NUDGE_AFTER_S;
}
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—";
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
@@ -405,6 +534,36 @@ const ActionBar = {
const stack = Betting.stackOf(this.state, player);
const who = HoldemState.displayName(player, index);
// Time is up. The ruling goes on the bar in the words the dealer says out loud,
// with the one action that carries it out — and the ordinary buttons stay
// underneath, because a player who speaks up as the count ends is still acting
// and the dealer is the one who decides whether it was in time.
const expired = this.clockLeft() === 0 ? this.clockRuling() : null;
const wrap = document.createElement("div");
if (expired) {
const line = document.createElement("p");
line.className = "clockExpired";
// The ruling in one sentence. `label` is the same call in three words for
// the button beside it; saying both left the line doubling back on itself
// — "Time — Dee's hand is dead — Dee is facing $2, so the hand is dead."
line.textContent = `Time — ${expired.say}`;
wrap.appendChild(line);
const enforce = document.createElement("div");
enforce.className = "actionButtons";
enforce.appendChild(this.button(
expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
"actionEnforceClock",
() => this.commit(
expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`,
() => {
this.stopClock();
if (expired.verb === "fold") Betting.fold(this.state, index);
else Betting.check(this.state, index);
})));
wrap.appendChild(enforce);
}
const row = document.createElement("div");
row.className = "actionButtons";
@@ -431,7 +590,9 @@ const ActionBar = {
}));
}
return row;
if (!expired) return row;
wrap.appendChild(row);
return wrap;
},
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
@@ -459,6 +459,96 @@ const Betting = {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
// Chips the pot has been priced with but nobody has actually paid.
//
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
// bet leaves the stack, sits in front of the player, and is swept into their hand
// total at the end of the street. The other way is the "without live betting" panel
// — a base pot amount, or chips counted out on a seat — where the dealer is
// recording a hand they are watching rather than driving, and nothing ever leaves
// anybody's stack.
//
// The pot wizard prices both the same way, and awarding it credits the winner. So
// in that second mode the table simply grew: measured at a three-handed table with
// a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
// left the bank check blaming a rebuy that never happened.
//
// So before the pot is pushed, whatever was assumed rather than taken comes off the
// stacks that assumed it. Chips already committed or swept are left alone; they
// have been paid once already, and taking them twice is the same bug the other way
// round.
collectAssumedContributions(state) {
const taken = [];
state.seats.forEach((player, index) => {
const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
const owed = this.round(priced - alreadyPaid);
if (owed <= this.EPSILON_CHIPS) return;
// Never more than they are holding. A seat priced above its own stack is a
// dealer's typo or a player who is short, and inventing the difference is
// what this whole function exists to stop.
const moved = this.round(Math.min(owed, this.stackOf(state, player)));
if (moved <= this.EPSILON_CHIPS) return;
player.stack = this.round(this.stackOf(state, player) - moved);
// Recorded where a swept bet lives, so the money is in exactly the place
// the rest of the tool looks for chips that are in the middle this hand.
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
});
return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
},
// A misdeal: the hand never happened, so every chip goes back where it came from.
//
// The rulings sheet in this tool has told dealers to redeal since the day it was
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one
// of the first two cards exposed — and the tool had no way to do it. The only
// control that ended a live hand was Next Hand, which says out loud that it takes
// the money off the table without paying anybody. That is right for an abandoned
// pot and wrong for a misdeal, where the blinds and any calls belong to the people
// who put them in.
//
// Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
// while the action history is still there. It lives in memory, so a dealer who
// refreshes the page mid-hand, or who is three calls in, has nothing.
//
// What is deliberately kept: the button, the blinds, the straddle and anyone
// posting in. A misdeal is the *same* hand dealt again, not the next one.
takeHandBack(state) {
const returned = [];
state.seats.forEach((player, index) => {
const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
if (back > 0) {
player.stack = this.round(this.stackOf(state, player) + back);
returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
}
player.committed = 0;
player.amountOverride = null;
player.hasActed = false;
player.folded = false;
player.cards = ["", ""];
});
state.bettingOpen = false;
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
state.actingSeatIndex = null;
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
// Nothing was posted, as far as the next rotation is concerned: the hand this
// would have counted as is being dealt again from the same button.
state.postedBlinds = null;
state.postedStraddles = [];
return {
returned,
total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)),
};
},
// Pulls the blinds back off the table and posts them again from wherever the
// button and the blind assignment now say they belong. Refunding first is what
// keeps the chip count straight when the button gets passed round three or four
@@ -54,7 +54,10 @@ const HandHistoryExport = {
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
standings.forEach(row => {
rows.push([
row.name,
// Somebody who has gone home is marked in the file the same way the
// panel marks them, because this is the copy that gets read at the
// end of the night when nobody remembers who has already been paid.
row.cashedOut ? `${row.name} (paid out)` : row.name,
Money.forSheet(row.in),
Money.forSheet(row.won),
Money.forSheet(row.net),
+125 -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),
};
@@ -96,6 +125,39 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
// Somebody is done for the night. The dealer counts their chips out, the chair
// opens, and both halves of what happened stay on the record — the cash the box
// took from them, and the chips it handed back. Wiping the seat by hand did
// neither: it left the bank check declaring their whole stack missing for the
// rest of the night.
cashOut: async index => {
const allowed = HoldemState.canCashOut(state, index);
if (!allowed.ok) {
Toast.show(allowed.reason, { duration: 4000 });
return;
}
const preview = HoldemState.cashOutPreview(state, index);
const result = preview.net === 0
? "even for the night"
: preview.net > 0
? `up ${Money.format(preview.net)}`
: `down ${Money.format(Math.abs(preview.net))}`;
const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
const ok = await Dialog.confirm({
title: `Cash out ${preview.name}?`,
message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
`${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
`night's record keeps both their buy-in and this payout so the bank still balances.`,
confirmLabel: `Pay ${Money.format(preview.chips)}`,
});
if (!ok) return;
runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
() => HoldemState.cashOut(state, index));
},
// Two players change chairs. Undoable like every other seat action, because a
// swap is easy to aim wrong and the seats look alike at a glance.
swapSeats: (from, to) => {
@@ -199,6 +261,11 @@ document.addEventListener("DOMContentLoaded", () => {
if (rake > 0) {
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
}
// Anything the pot was priced with but nobody actually paid — the
// base pot and counted chips of the no-live-betting flow — comes off
// the stacks that owe it before the winner is paid. Otherwise the
// table gains the whole pot out of nowhere.
Betting.collectAssumedContributions(state);
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts, rake);
// Winnings go straight back in front of the player, so the next
@@ -304,12 +371,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));
@@ -368,6 +434,59 @@ document.addEventListener("DOMContentLoaded", () => {
Render.renderAll();
});
// 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;
const ok = await Dialog.confirm({
title: "Misdeal — take the hand back?",
message: inPlay > Betting.EPSILON_CHIPS
? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` +
`button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}`
: "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.",
confirmLabel: "Take it back",
});
if (!ok) return;
// The label is both the toast a dealer reads now and the words on the undo
// control afterwards, so it says what happened to the money rather than naming
// the ruling: "the misdeal" told a table nothing about where their chips went.
const label = inPlay > Betting.EPSILON_CHIPS
? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.`
: "Misdeal — nothing was in yet, same button deals again.";
runUndoable(label, () => Betting.takeHandBack(state));
});
document.getElementById("nextHandBtn").addEventListener("click", async () => {
// Moving on wipes every seat's contribution, so an unresolved pot is simply
// destroyed — measured at a four-handed table, pressing this with $8 in the
@@ -33,6 +33,7 @@ const Render = {
this.renderNotes();
this.updatePotBadge();
this.renderSetupFields();
this.renderHeaderActions();
this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
@@ -62,6 +63,16 @@ const Render = {
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
},
// Misdeal is only a thing you can call on a hand that is out. Before the deal there
// is nothing to take back, and afterwards the header would be offering to unwind a
// hand that has already been paid — so the button comes and goes with the hand
// rather than sitting there all night waiting to be pressed by accident.
renderHeaderActions() {
const misdeal = document.getElementById("misdealBtn");
if (!misdeal) return;
misdeal.hidden = !this.state.bettingOpen;
},
// Whether this device can actually drag a seat. HTML5 drag events are never
// generated from a touch screen, so on a phone the last clause of the hint below
// described something impossible — on the device the rest of the sentence was
@@ -925,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);
@@ -1012,7 +1029,14 @@ const Render = {
const template = document.getElementById("standingsRowTemplate");
standings.forEach(row => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".standingsName").textContent = row.name;
// Somebody who has gone home is still on the list — they played, and the
// box took their cash — but the number beside them is money already handed
// over, not chips waiting to be counted. Marked, or a dealer settling up at
// 2am pays them twice.
node.classList.toggle("standingsGone", !!row.cashedOut);
node.querySelector(".standingsName").textContent = row.cashedOut
? `${row.name} · paid out`
: row.name;
node.querySelector(".standingsIn").textContent = Money.format(row.in);
node.querySelector(".standingsWon").textContent = Money.format(row.won);
@@ -1040,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
@@ -1169,25 +1247,66 @@ const Render = {
}
}
// Chips handed back to people who have gone home. The third way money leaves
// this table on purpose, and the one a dealer is asked about most: "where did
// Ann's four hundred go?"
const paidRow = document.getElementById("bankPaidOutRow");
if (paidRow) {
paidRow.hidden = !(bank.paidOut > 0);
if (bank.paidOut > 0) {
document.getElementById("bankPaidOut").textContent = Money.format(bank.paidOut);
}
}
const verdict = document.getElementById("bankReconcileVerdict");
// What has left the table on purpose, whichever way it went.
const accountedFor = [
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
bank.paidOut > 0 ? `${Money.format(bank.paidOut)} paid out to players who have left` : null,
].filter(Boolean);
if (bank.balanced) {
verdict.textContent = accountedFor.length > 0
? `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,6 +47,27 @@ 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 (HoldemState.seatInPlay(player)) {
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
+262 -6
View File
@@ -110,6 +110,15 @@ const HoldemState = {
// What it has taken so far tonight. A session total, not a per-hand one:
// it is the number the box is checked against at the end.
rakeTaken: 0,
// People who have taken their chips and gone. Kept for the night rather
// than forgotten with the seat: the box took their cash and paid it back
// 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.
@@ -228,6 +237,27 @@ 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)
.map(row => ({
id: row.id,
name: String(row.name || "").slice(0, 40),
buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)),
chips: Math.max(0, Money.round(Number(row.chips) || 0)),
at: Number(row.at) || 0,
}))
: [];
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
@@ -753,6 +783,15 @@ const HoldemState = {
// Cash bought in and chips currently in front don't come from the hand log at
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
const seatById = new Map(state.seats.map(p => [p.id, p]));
// And for anyone who has gone home, off the record their cash-out left behind.
// Without this they read as a player who bought in for nothing and is holding
// nothing, in the very list the dealer settles the night from.
const goneById = new Map((state.cashedOut || []).map(row => [row.id, row]));
goneById.forEach((row, id) => {
if (totals.has(id)) return;
totals.set(id, { id, name: row.name, in: 0, won: 0 });
});
// Anyone who has money in the game belongs in the settle-up, whether or not
// they've been in a logged hand yet. Building the list from the hand log alone
@@ -764,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;
@@ -774,8 +813,16 @@ const HoldemState = {
return Array.from(totals.values())
.map(t => {
const seat = seatById.get(t.id);
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
const gone = goneById.get(t.id);
const boughtIn = seat
? Money.round(Number(seat.buyIns) || 0)
: (gone ? gone.buyIns : 0);
// What a departed player is "holding" is what the box handed them on
// 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
? 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
// position disagree with the hand log for as long as a hand is open —
@@ -806,6 +853,10 @@ const HoldemState = {
// a $340 stack read as "$140" at the exact moment cash changed
// hands.
chipsInFront: Money.round(chipsNow + inFrontOfThem),
// Set once they have taken their chips and left, so the panel and
// the export can say so rather than showing a player still sitting
// there with a stack.
cashedOut: !!gone && !seat,
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
notes,
};
@@ -838,6 +889,136 @@ 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
// had nothing for it. A dealer's only way to free the chair was to wipe the seat by
// hand, and then the bank check — which adds up cash taken against chips on the
// table — declared the departing player's whole stack missing: "someone has paid
// for chips they haven't been given", for the rest of the night. Wiping their
// buy-in as well silenced it, at the price of losing their night from the standings
// and the file the dealer hands over.
//
// Cashing out is two facts, and both have to survive the empty chair: the box took
// their cash, and the box paid their chips back out.
// Can this seat be cashed out right now?
canCashOut(state, index) {
const player = state.seats[index];
if (!player) return { ok: false, reason: "There's nobody in that seat." };
const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0));
if (inPot > 0) {
return {
ok: false,
// Paying someone out with money still in the middle would either take
// chips out of a pot other people can win, or hand them chips twice.
reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`,
};
}
return { ok: true };
},
// What the dealer counts out, and what the seat's night comes to.
cashOutPreview(state, index) {
const player = state.seats[index];
if (!player) return null;
// Betting owns the answer to "what is this seat holding" — including the rule
// that a seat which has never been given a stack is holding the table's
// starting one. Asking it rather than working it out again here: the first
// draft of this did, read `Number(null)` as a perfectly good zero, and offered
// to pay a player nothing.
const chips = Money.round(Math.max(0, Betting.stackOf(state, player)));
const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0));
return {
index,
name: this.displayName(player, index),
chips,
boughtIn,
// Their result for the night, in the direction a banker says it: positive
// means the box is paying out more than it took from them.
net: Money.round(chips - boughtIn),
breakdown: Money.formatChipBreakdown(chips, state.chipLegend),
};
},
// Hand them their chips and free the chair, keeping both halves on the record.
cashOut(state, index) {
const allowed = this.canCashOut(state, index);
if (!allowed.ok) return allowed;
const preview = this.cashOutPreview(state, index);
const leavingId = state.seats[index].id;
if (!Array.isArray(state.cashedOut)) state.cashedOut = [];
state.cashedOut.push({
id: leavingId,
name: preview.name,
buyIns: preview.boughtIn,
chips: preview.chips,
at: Date.now(),
});
// The chair opens for the next player. A fresh id, because the person who had
// it has gone and their night is now filed under the old one — reusing it would
// merge two people's results the moment somebody new sits down.
const seat = this.makePlayer();
state.seats[index] = seat;
// Anyone owed a hand by this seat is no longer owed one.
state.postingIn = (state.postingIn || []).filter(id => id !== leavingId);
return { ok: true, ...preview };
},
// ---- The house's cut ----
//
// A game that takes a rake or a time charge has chips leaving the table every hand,
@@ -899,12 +1080,82 @@ 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 cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
// Cash the box took from people who have since left counts as taken: it is in
// the box, and forgetting it with the seat is what used to make a cash-out read
// as a shortfall all night.
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) +
gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0);
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
@@ -922,12 +1173,17 @@ const HoldemState = {
// for a better reason: the box is supposed to have them.
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
// Chips handed back over the rail. Off the table like the other two, and the
// one the dealer can point at when somebody asks where the money went.
const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0));
const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
raked,
paidOut,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
+175
View File
@@ -0,0 +1,175 @@
// Chips the pot was priced with that nobody actually paid.
//
// There are two ways a contribution gets into this tool. Tapping the action moves
// chips: the bet leaves the stack, sits in front of the player, and is swept into their
// hand total at the end of the street. The other is the "without live betting" panel —
// a base pot amount, or chips counted out on a seat — where the dealer is recording a
// hand they are watching rather than driving, and nothing ever leaves a stack.
//
// The pot wizard prices both the same way and awarding it credits the winner, so in
// that second mode the table simply grew.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
const basePotTable = (base = 50, stack = 200) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 4);
state.basePotAmount = base;
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].stack = stack;
state.seats[i].buyIns = stack;
});
return state;
};
const pricedPot = (state) => state.seats.reduce(
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
describe("what nobody paid", () => {
test("comes off the stacks that owe it", () => {
const state = basePotTable();
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 150);
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
});
test("and lands where the rest of the tool looks for chips in the middle", () => {
const state = basePotTable();
Betting.collectAssumedContributions(state);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
assert.equal(HoldemState.unresolvedContributions(state), 150);
});
test("chips counted out on a seat are money too", () => {
const state = basePotTable(0);
state.seats[0].chipCounts = { red: 4 }; // four $5 chips
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 20);
assert.equal(state.seats[0].stack, 180);
});
});
describe("what has already been paid", () => {
test("a live bet is left exactly alone", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
Betting.beginHand(state);
const before = state.seats.map(p => Betting.stackOf(state, p));
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 0, "the blinds are already off their stacks");
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("nor is a swept street taken a second time", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
Betting.beginHand(state);
Betting.sweepToHandTotals(state);
const before = state.seats.map(p => Betting.stackOf(state, p));
assert.equal(Betting.collectAssumedContributions(state).total, 0);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("a base pot on top of a live bet only collects the difference", () => {
// The base pot says "assume everyone is in for fifty". Somebody who has already
// put twenty in owes thirty of it, not fifty.
const state = basePotTable(50);
state.seats[0].committed = 20;
state.seats[0].stack = 180;
const collected = Betting.collectAssumedContributions(state);
const ann = collected.taken.find(row => row.name === "Ann");
assert.equal(ann.amount, 30);
assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
});
});
describe("what it will not do", () => {
test("take more than a player is holding", () => {
const state = basePotTable(50);
state.seats[1].stack = 20;
Betting.collectAssumedContributions(state);
assert.equal(state.seats[1].stack, 0, "all of it, and no further");
assert.equal(state.seats[1].amountOverride, 20);
});
test("touch an empty chair", () => {
const state = basePotTable();
const before = { ...state.seats[3] };
Betting.collectAssumedContributions(state);
assert.equal(state.seats[3].stack, before.stack);
assert.equal(state.seats[3].amountOverride, before.amountOverride);
});
test("or a seat that isn't in the hand", () => {
// The base pot stands in for "everyone put the same in", and somebody sitting
// out did not.
const state = basePotTable();
state.seats[2].inHand = false;
const collected = Betting.collectAssumedContributions(state);
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
assert.equal(state.seats[2].stack, 200);
});
});
describe("the whole hand", () => {
test("conserves the chips on the table, which it did not before", () => {
const state = basePotTable();
const total = chipsOnTable(state);
const pot = pricedPot(state);
Betting.collectAssumedContributions(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("and the bank check stays balanced all the way through", () => {
const state = basePotTable();
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.collectAssumedContributions(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
HoldemState.startNextHand(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("a live-betting hand is unchanged by any of this", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
const total = chipsOnTable(state);
Betting.beginHand(state);
[3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
Betting.sweepToHandTotals(state);
const pot = HoldemState.unresolvedContributions(state);
Betting.collectAssumedContributions(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), total);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("collecting twice takes nothing the second time", () => {
// The pot wizard can be opened, cancelled and opened again.
const state = basePotTable();
Betting.collectAssumedContributions(state);
const stacks = state.seats.map(p => p.stack);
const again = Betting.collectAssumedContributions(state);
assert.equal(again.total, 0);
assert.deepEqual(state.seats.map(p => p.stack), stacks);
});
});
+129
View File
@@ -0,0 +1,129 @@
// Calling the clock on a player.
//
// The bar already had a stopwatch saying how long somebody had been thinking. This is
// what a dealer does about it: a procedure with a shape — a set time, a countdown said
// out loud, and a ruling at the end that depends on what the player was facing.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar, Betting, makeTable } = require("./load");
// The bar rebuilds the DOM when a clock starts; none of the rules below need it.
const onTable = (state) => {
ActionBar.state = state;
ActionBar.render = () => {};
ActionBar.stopClock();
return ActionBar;
};
const dealt = () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
Betting.beginHand(state);
return state;
};
describe("starting one", () => {
test("it runs on the player who is actually on the clock", () => {
const state = dealt();
const bar = onTable(state);
assert.equal(bar.clockRunning(), false, "nothing runs until it is called");
bar.callClock();
assert.equal(bar.clockRunning(), true);
assert.equal(bar.clockSeatIndex, state.actingSeatIndex);
});
test("with nobody to act there is nothing to call", () => {
const state = dealt();
state.actingSeatIndex = null;
const bar = onTable(state);
bar.callClock();
assert.equal(bar.clockRunning(), false);
assert.equal(bar.clockLeft(), null);
});
test("the dealer can put it away again", () => {
const bar = onTable(dealt());
bar.callClock();
bar.stopClock();
assert.equal(bar.clockRunning(), false);
assert.equal(bar.clockLeft(), null);
});
});
describe("the count", () => {
const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000);
test("is the player's own time and then a countdown", () => {
const bar = onTable(dealt());
bar.callClock();
assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S);
assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts");
assert.equal(at(bar, 35), 5);
});
test("stops at zero rather than going negative", () => {
const bar = onTable(dealt());
bar.callClock();
assert.equal(at(bar, 40), 0);
assert.equal(at(bar, 600), 0, "ten minutes later it is still just up");
});
});
describe("what happens at zero", () => {
test("facing a bet, the hand is dead", () => {
const state = dealt();
const bar = onTable(state);
// Whoever is up pre-flop is facing the big blind.
const ruling = bar.clockRuling();
assert.equal(ruling.verb, "fold");
assert.match(ruling.label, /hand is dead/);
assert.match(ruling.say, /facing \$2/);
});
test("facing nothing, it is checked — slowness doesn't fold a free hand", () => {
const state = dealt();
// Everyone calls round to the big blind, who has nothing to call.
[3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat));
const bar = onTable(state);
assert.equal(state.actingSeatIndex, 2, "the big blind is up");
const ruling = bar.clockRuling();
assert.equal(ruling.verb, "check");
assert.match(ruling.label, /check for/i);
});
test("the ruling names the player, because the dealer has to say it out loud", () => {
const state = dealt();
state.seats[state.actingSeatIndex].name = "Priya";
const ruling = onTable(state).clockRuling();
assert.match(ruling.label, /Priya/);
assert.match(ruling.say, /Priya/);
});
test("with nobody to act there is no ruling to make", () => {
const state = dealt();
state.actingSeatIndex = null;
assert.equal(onTable(state).clockRuling(), null);
});
});
describe("what ends it", () => {
test("the player acting", () => {
const state = dealt();
const bar = onTable(state);
bar.callClock();
const wasOn = state.actingSeatIndex;
Betting.call(state, wasOn);
assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved");
assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's");
});
test("and it doesn't follow the action to the next player", () => {
// A clock is called on one decision. Left running it would put a countdown on
// somebody nobody asked about.
const state = dealt();
const bar = onTable(state);
bar.callClock();
Betting.call(state, state.actingSeatIndex);
assert.equal(bar.clockLeft(), null);
});
});
+229
View File
@@ -0,0 +1,229 @@
// Somebody leaving with their chips.
//
// The most ordinary thing that happens at a home game after midnight, and the tool had
// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
// check — cash taken against chips on the table — declared the departing player's whole
// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
// price of losing their night from the standings and the file the dealer hands over.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, Betting, makeTable } = require("./load");
const table = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].buyIns = 200;
});
return state;
};
describe("counting them out", () => {
test("the preview is what the dealer's hands follow", () => {
const state = table();
state.seats[0].stack = 350;
const preview = HoldemState.cashOutPreview(state, 0);
assert.equal(preview.name, "Ann");
assert.equal(preview.chips, 350, "what they are holding");
assert.equal(preview.boughtIn, 200);
assert.equal(preview.net, 150, "up for the night");
assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
});
test("a player who is down reads as down, not as a smaller stack", () => {
const state = table();
state.seats[1].stack = 60;
assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
});
test("a seat that never set a stack is holding the table's starting stack", () => {
const state = table();
state.defaultStack = 200;
state.seats[2].stack = null;
assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
});
});
describe("what it refuses", () => {
test("paying somebody with money still in the middle", () => {
const state = table();
Betting.beginHand(state);
const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
const refusal = HoldemState.canCashOut(state, bb);
assert.equal(refusal.ok, false);
assert.match(refusal.reason, /in the pot/);
// And the refusal is the whole story: nothing moved.
assert.equal(HoldemState.cashOut(state, bb).ok, false);
assert.equal((state.cashedOut || []).length, 0);
assert.ok(state.seats[bb].name, "the seat is untouched");
});
test("an empty chair, which has nobody to pay", () => {
const state = table();
const refusal = HoldemState.canCashOut(state, 99);
assert.equal(refusal.ok, false);
});
});
describe("the chair afterwards", () => {
test("is empty and ready for the next player", () => {
const state = table();
state.seats[0].stack = 350;
HoldemState.cashOut(state, 0);
const seat = state.seats[0];
assert.equal(seat.name, "");
assert.equal(seat.stack, null);
assert.equal(seat.buyIns, 0);
assert.equal(seat.inHand, false);
});
test("belongs to somebody new, so two people's nights can't merge", () => {
const state = table();
const wasId = state.seats[0].id;
HoldemState.cashOut(state, 0);
assert.notEqual(state.seats[0].id, wasId);
});
test("and isn't owed the blind the person before them was posting", () => {
const state = table();
state.postingIn = [state.seats[0].id, state.seats[1].id];
HoldemState.cashOut(state, 0);
assert.deepEqual(state.postingIn, [state.seats[1].id]);
});
});
describe("the bank check", () => {
test("balanced before, balanced after — the chips went somewhere known", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50;
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
HoldemState.cashOut(state, 0);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.paidOut, 350);
assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
assert.equal(bank.chipsInPlay, 450);
assert.equal(bank.balanced, true);
});
test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50; // so the table starts out balanced
// What a dealer had to do before: empty the chair themselves.
state.seats[0].name = "";
state.seats[0].stack = null;
state.seats[0].inHand = false;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -350, "the tool called it missing money");
});
test("chips that really are missing still show as missing", () => {
// Counting a cash-out must not become a way of swallowing a genuine shortfall.
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50; // balanced to start with
HoldemState.cashOut(state, 0);
state.seats[2].stack = 150; // and now fifty chips have gone astray
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -50);
});
test("a rake and a cash-out are counted apart", () => {
const state = table();
state.seats[0].stack = 350;
state.rakeTaken = 12;
state.seats[1].stack = 38; // the rake came out of Ben's stack
HoldemState.cashOut(state, 0);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.raked, 12);
assert.equal(bank.paidOut, 350);
assert.equal(bank.balanced, true);
});
});
describe("the record of their night", () => {
test("they stay in the standings with what they bought and what they left with", () => {
const state = table();
state.seats[0].stack = 350;
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
HoldemState.cashOut(state, 0);
const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
assert.ok(ann, "still on the list");
assert.equal(ann.boughtIn, 200);
assert.equal(ann.chipsInFront, 350, "what the box handed over");
assert.equal(ann.cashPosition, 150);
assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
});
test("somebody still sitting there is not marked", () => {
const state = table();
HoldemState.cashOut(state, 0);
const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
assert.equal(ben.cashedOut, false);
});
test("two people leaving are both kept", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50;
HoldemState.cashOut(state, 0);
HoldemState.cashOut(state, 1);
assert.equal(state.cashedOut.length, 2);
assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("a table saved before any of this existed has nobody cashed out", () => {
const parsed = JSON.parse(JSON.stringify(table()));
delete parsed.cashedOut;
const migrated = HoldemState.migrate(parsed);
assert.deepEqual(migrated.cashedOut, []);
assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
});
test("nonsense in a saved record is cleaned up rather than trusted", () => {
const parsed = JSON.parse(JSON.stringify(table()));
parsed.cashedOut = [
{ id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
{ name: "no id" },
null,
];
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.cashedOut.length, 1);
assert.equal(migrated.cashedOut[0].buyIns, 0);
assert.equal(migrated.cashedOut[0].chips, 350);
assert.equal(migrated.cashedOut[0].at, 0);
});
});
describe("the table carries on", () => {
test("the next hand deals round the empty chair", () => {
const state = table();
state.seats[0].stack = 350;
HoldemState.cashOut(state, 0);
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
assert.equal(Betting.liveSeats(state).length, 3);
assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
});
test("and the money still adds up once it has", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50;
HoldemState.cashOut(state, 0);
HoldemState.startNextHand(state);
Betting.beginHand(state);
const bank = HoldemState.bankReconciliation(state);
assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
assert.equal(bank.balanced, true);
});
});
+155
View File
@@ -0,0 +1,155 @@
// Taking a hand back.
//
// The rulings sheet in this tool has told dealers to redeal since the day it was
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one of
// the first two cards exposed — and the tool had no way to do it. The only control that
// ended a live hand was Next Hand, which says out loud that it takes the money off the
// table without paying anybody. That is right for an abandoned pot and wrong for a
// misdeal, where the blinds and the calls belong to the people who put them in.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const dealt = (extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10, dealerSeatIndex: 0, ...extra });
Betting.beginHand(state);
return state;
};
describe("the money", () => {
test("goes back to the people who put it in", () => {
const state = dealt();
const before = state.seats.map(p => Betting.stackOf(state, p) + Betting.committed(p));
Betting.takeHandBack(state);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("including calls and raises, not just the blinds", () => {
const state = dealt();
Betting.raiseTo(state, state.actingSeatIndex, 40);
Betting.call(state, state.actingSeatIndex);
assert.ok(Betting.potSoFar(state) > 50, "there is real money in the middle");
const taken = Betting.takeHandBack(state);
assert.equal(taken.total, 95, "blinds and both wagers");
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
});
test("and across streets, once chips have been swept into hand totals", () => {
const state = dealt();
[3, 4, 5, 0, 1, 2].forEach(seat => {
if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
});
Betting.closeStreet(state);
assert.ok(state.seats.some(p => (Number(p.amountOverride) || 0) > 0), "the flop's money is banked");
Betting.takeHandBack(state);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
assert.deepEqual(state.seats.map(p => p.amountOverride), [null, null, null, null, null, null]);
});
test("the table has exactly the chips it started with", () => {
const fresh = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
const total = chipsOnTable(fresh);
const state = dealt();
Betting.raiseTo(state, state.actingSeatIndex, 40);
Betting.takeHandBack(state);
assert.equal(chipsOnTable(state), total);
});
test("nothing is thrown away — the discard pile doesn't move", () => {
const state = dealt();
state.discarded = 8;
Betting.takeHandBack(state);
assert.equal(state.discarded, 8, "a misdeal is not an abandoned pot");
});
test("it says what went back and to whom", () => {
const state = dealt();
const taken = Betting.takeHandBack(state);
assert.equal(taken.returned.length, 2, "the two blinds");
assert.deepEqual(taken.returned.map(r => r.amount).sort((a, b) => a - b), [5, 10]);
assert.ok(taken.returned.every(r => r.name), "named, because the dealer says it out loud");
});
});
describe("the table afterwards", () => {
test("is waiting to deal the same hand again", () => {
const state = dealt();
Betting.takeHandBack(state);
assert.equal(state.bettingOpen, false, "the dealer says when");
assert.equal(state.street, "preflop");
assert.equal(state.actingSeatIndex, null);
assert.equal(state.currentBet, 0);
});
test("keeps the button where it was — a misdeal is not the next hand", () => {
const state = dealt({ dealerSeatIndex: 3 });
const handNumber = state.handNumber;
Betting.takeHandBack(state);
assert.equal(state.dealerSeatIndex, 3);
assert.equal(state.handNumber, handNumber, "no hand has been played");
assert.equal((state.handHistory || []).length, 0, "and none is logged");
});
test("keeps the straddle, which was announced and still stands", () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
state.straddleCount = 1;
Betting.beginHand(state);
assert.deepEqual(state.postedStraddles, [3]);
Betting.takeHandBack(state);
assert.equal(state.straddleCount, 1, "still on for the redeal");
assert.deepEqual(state.postedStraddles, [], "but nothing is out there yet");
Betting.beginHand(state);
assert.deepEqual(state.postedStraddles, [3], "and it goes out again");
});
test("clears the cards and the folds, because none of it happened", () => {
const state = dealt();
state.seats[3].cards = ["As", "Kd"];
Betting.fold(state, 3);
Betting.takeHandBack(state);
assert.deepEqual(state.seats[3].cards, ["", ""]);
assert.equal(state.seats[3].folded, false);
assert.ok(state.seats.every(p => !p.hasActed));
});
test("deals again cleanly, with the same blinds on the same players", () => {
const state = dealt();
const first = state.seats.map(p => Betting.committed(p));
Betting.takeHandBack(state);
Betting.beginHand(state);
assert.deepEqual(state.seats.map(p => Betting.committed(p)), first);
});
});
describe("the bank still balances", () => {
test("through a misdeal and the redeal after it", () => {
const state = dealt();
state.seats.forEach(p => { p.buyIns = 200; });
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.raiseTo(state, state.actingSeatIndex, 60);
Betting.takeHandBack(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.beginHand(state);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, true);
assert.equal(bank.cashIn, 1200);
});
});
describe("taking back a hand nobody had money in", () => {
test("is harmless: no blinds, nothing moves", () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 0, bigBlind: 0 });
Betting.beginHand(state);
const taken = Betting.takeHandBack(state);
assert.equal(taken.total, 0);
assert.deepEqual(taken.returned, []);
assert.equal(state.bettingOpen, false);
});
});
+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");
});
});