Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 42b9e83e8f Don't deal the hand when the dealer names a blind
The seat menu's "Make small blind" / "Make big blind" / "Blinds follow the
button again" all re-posted the blinds whenever `Betting.onlyBlindsIn(state)`
was true. That condition means "nothing has happened in this hand except the
blinds" — which is true of a hand one second old and equally true of a table
sitting in setup with no cards out and nothing committed.

So naming a blind before the deal ran repostBlinds -> beginHand: betting
opened, two stacks docked, the action bar up and waiting on the seat after the
big blind. The dealer had said where the blinds go and the tool heard "deal".

Betting.blindsArePosted() is the question these three actually wanted — the
blinds are down only once a hand is open — and it's the guard makeDealer was
already using by hand. Before the deal, naming a blind now moves a badge and
nothing else; the toast says "Dee has the small blind next hand." rather than
claiming she posted it. Mid-hand behaviour is unchanged: chips come back and
go in again from the new positions, and the chip count stays straight.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 11:11:34 -06:00
jheaps 470fbc0d26 Add the seat-name round to the release 2026-09-02 09:52:35 -06:00
jheapsandClaude Opus 5 c4e842f3e3 Stop the button's chip sitting on top of the name underneath it
A phone screenshot, mid-hand, eight-handed: the seat with the button reads
"Christop" and then a white D. The name is centred across the whole puck and the
chip sits in its top-right corner on a layer above, so a long name runs
underneath and stops — measured, "Christopher" lost 20px of its 64px box.

The bad part is the silence. The text believed it had the room it was being
painted out of, so no ellipsis appeared: nothing on screen said the name went on.
On the one seat at the table a dealer looks at most.

The name now keeps out of the chip's corner on the seat that has the button, so a
long one ends in an ellipsis before it gets there. Only that seat — every other
puck has its full width, because the chip on those is invisible.

And the audit has a check for it now. Nothing it had would have caught this: the
occlusion check is about controls a thumb has to reach, and this is a label a
dealer has to read. The new one measures every seat's name against its own chip
and badges, and the fix was undone by hand to confirm it fires — "20px under
button.dealerChip.active".

182 audits across 13 viewports, both input kinds, plain and straddled tables,
every overlay: clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:52:00 -06:00
jheaps 1d9353c94e Add the hidden-means-hidden round to the release 2026-09-02 09:37:29 -06:00
jheapsandClaude Opus 5 8616636212 Make hidden mean hidden, and check that it does
Three rounds inside the money, so this one was a look at the thing. On a desktop
screenshot of a table mid-session there is a small empty box under the bank
check: 21 pixels wide, 44 tall, no text, no purpose. It is the "Take $600 from 3
players" button from three rounds ago, which sets `hidden` when there is nothing
to record — and `hidden` was losing.

The attribute carries its `display: none` from the browser's own stylesheet,
which is the weakest rule in the cascade, so any class that sets a display beats
it. Every button class here sets `inline-flex`. This is not a new discovery in
this file either: seven overlays already carry their own `[hidden] { display:
none }`, each one presumably found the same way. One global rule now covers all
of them, so there is no ninth.

The audit had been telling me. Its touch check reported the box as "could not be
probed (drawn 21x44)" — in the warnings, which my sweeps printed only sometimes
and never failed on. So the check now says the plainer thing: an element that
claims to be hidden and is drawn anyway is a fault, not a note. It is in runAll
with the rest, and the bug was put back by hand to confirm it fires.

The same warning stream had a second thing in it: the waiting list's Add button
was 43px wide. Three letters, one pixel under the rule, and a thumb doesn't know
the word is short.

182 audits across 13 viewports, both input kinds, every overlay and a table with
a rake, a cash-out, a waiting list and six logged hands on it: clean, and the
only note left is the one that says the 44px rule stood itself down on a mouse.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:36:51 -06:00
jheaps 6174981cdc Add the assumed-contributions round to the release 2026-09-02 09:21:57 -06:00
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
jheaps 0ad5592687 Add the one-stack-rule round to the release 2026-09-02 09:06:07 -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
jheaps 236c52567e Add the starting-stack accounting round to the release 2026-09-02 08:53:35 -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
jheaps b8655be258 Add the waiting-list round to the release 2026-09-02 08:38:21 -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
jheaps 7e420ce747 Add the misdeal round to the release 2026-09-02 08:24:05 -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
jheaps c36d7f60bd Add the cash-out round to the release 2026-09-02 08:09:26 -06:00
jheaps 15cec18256 Add the clock round to the release 2026-09-02 05:40:45 -06:00
jheaps 9c8d981e2d Add the house-take round to the release 2026-09-02 05:13:11 -06:00
jheaps 907e0165db Add the stadium ring round to the release 2026-09-02 04:59:37 -06:00
jheaps 86e1570618 Add the phone felt round to the release 2026-09-02 04:34:05 -06:00
jheaps 6df400322e Add the audit round to the release 2026-09-02 04:03:25 -06:00
jheaps ca9ac7b6af Add the straddle round to the release 2026-09-02 03:32:58 -06:00
jheaps 80dae56f0d Add the landscape round to the release 2026-09-02 03:13:05 -06:00
jheaps 6ca99fde6b Bring master's already-merged rounds into the release branch
Master merged #58-#61 (manual deal, less guessing, seat editor, landscape
table) while the stack kept going. Those commits are already in this branch's
history; this merge just reconciles the two shapes so the release can go in as
one.
2026-09-02 01:19:56 -06:00
jheaps b2262f8604 Merge pull request 'Wait to be told to deal, instead of guessing' (#58) from fix/casino-holdem-manual-deal into master
Build and Deploy / build-deploy (push) Successful in 1m10s
Reviewed-on: #58
2026-09-01 10:57:23 -06:00
jheaps 6ad9923aae Merge pull request 'Stop the last two things the table did behind the dealer's back' (#59) from fix/casino-holdem-less-guessing into fix/casino-holdem-manual-deal
Reviewed-on: #59
2026-09-01 10:57:17 -06:00
jheaps 168e8a380c Merge pull request 'Tapping a seat gives you that seat' (#60) from feature/casino-holdem-seat-editor into fix/casino-holdem-less-guessing
Reviewed-on: #60
2026-09-01 10:57:10 -06:00
jheaps edc08d7213 Merge pull request 'Let the landscape table use the width it has' (#61) from fix/casino-holdem-landscape-table into feature/casino-holdem-seat-editor
Reviewed-on: #61
2026-09-01 10:57:01 -06:00
14 changed files with 1580 additions and 25 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>
@@ -167,6 +176,7 @@
<span id="bankPaidOut"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
</div>
<div id="settleUp" hidden>
+105
View File
@@ -318,6 +318,20 @@ button,
margin-top: 1rem;
}
/* `hidden` means hidden.
The attribute only carries `display: none` from the browser's own stylesheet, which
is the weakest rule there is: any class that sets a display — and every button here
sets `inline-flex` — beats it. That is not a hypothetical. Seven overlays in this
file already carry their own `[hidden] { display: none }` because each one was found
the hard way, and the eighth was a button that shipped as a 21x44 empty box under the
bank check on every table that didn't need it.
One rule, for everything, so there is no ninth. */
[hidden] {
display: none !important;
}
/* ---------- Poker table ---------- */
#pokerTableWrap {
@@ -608,6 +622,14 @@ button,
margin-top: 0.6rem;
}
/* The button's chip sits in this puck's top-right corner, on a layer above the text.
Reserving its width means a long name ends in an ellipsis before it reaches the
chip, rather than disappearing underneath one — on the one seat at the table a
dealer looks at most. */
.seat.seatHasButton .seatPuckName {
padding-right: 22px;
}
.seatPuckCards {
font-size: 0.68rem;
font-weight: 700;
@@ -2312,6 +2334,89 @@ body.hasActionBar #holdemSidebar {
font-style: italic;
}
/* The people standing behind the chairs. A short list in a panel that already has a
lot in it, so it is built from rows rather than a table: a name, the seat it can go
in, and a way off the list. */
.waitingAdd {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.waitingAdd input {
flex: 1;
min-width: 0;
min-height: 44px;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 8px;
color: #eee;
font-family: inherit;
/* 16px, or iOS zooms the whole page in when it takes focus. */
font-size: 16px;
padding: 0.35rem 0.6rem;
}
.waitingAdd .holdemBtn {
flex: none;
min-height: 44px;
/* "Add" is three letters, which came out 43px wide — one pixel under the rule, and
the audit called it. A thumb doesn't know the word is short. */
min-width: 56px;
}
.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 {
@@ -459,6 +459,115 @@ const Betting = {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
// Are the blinds actually down? Moving the button or naming a blind means two
// completely different things depending on the answer, and `onlyBlindsIn` alone
// cannot tell them apart: a table sitting in setup, with no cards out and nothing
// committed, satisfies it just as well as a hand that has only just been dealt.
//
// With a hand open, the blinds are chips on the felt. Re-aiming them has to take
// those chips back and post them again from the new positions, or the badges and
// the money disagree.
//
// Before the deal it is a marker being slid round a table with nothing in the
// middle. Re-posting there would *deal the hand* — betting opened, blinds taken off
// two stacks, action on the seat after the big blind — which is not what a dealer
// means by "she's the big blind this one". Setting the positions is how a dealer
// gets ready to deal; the cards come out when they press the button, and not a
// moment before.
blindsArePosted(state) {
return !!state.bettingOpen && this.onlyBlindsIn(state);
},
// 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
+110 -14
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),
};
@@ -147,8 +176,7 @@ document.addEventListener("DOMContentLoaded", () => {
// After it, the blinds have to follow the button or the badges and the
// money disagree — but that's a real thing happening to real chips, and
// the dealer gets told rather than finding out later.
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
const willRepost = Betting.blindsArePosted(state);
seatAction(index, willRepost
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
@@ -175,18 +203,29 @@ document.addEventListener("DOMContentLoaded", () => {
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
const label = which === "sb" ? "small blind" : "big blind";
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
// Only a hand that is already open has blinds to move. Before the deal this
// is aiming the next hand, and re-posting would have dealt it — the dealer
// says who the blind is well before they say "cards".
const posted = Betting.blindsArePosted(state);
seatAction(index, posted
? `${nameOf(index)} posts the ${label}.`
: `${nameOf(index)} has the ${label} next hand.`, () => {
state.blindOverride = which === "sb"
? { sbIndex: index, bbIndex: partner }
: { sbIndex: partner, bbIndex: index };
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
if (posted) Betting.repostBlinds(state);
});
},
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
state.blindOverride = null;
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
}),
clearBlindOverride: () => {
const posted = Betting.blindsArePosted(state);
seatAction(state.dealerSeatIndex, posted
? "Blinds follow the button again — taken back and re-posted."
: "Blinds follow the button again.", () => {
state.blindOverride = null;
if (posted) Betting.repostBlinds(state);
});
},
editSeat: index => Render.toggleSeatEditor(index),
@@ -232,6 +271,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
@@ -337,12 +381,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));
@@ -401,6 +444,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
@@ -397,6 +408,12 @@ const Render = {
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
// Marked on the seat as well as the chip, so the puck's own text can keep
// out from under it. A name is centred across the whole puck and the chip
// sits in the corner on a layer above: measured, "Christopher" on the
// button lost 20px of its 64px box to the chip and showed no ellipsis,
// because the text believed it had the room it was painted out of.
node.classList.toggle("seatHasButton", hasButton);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
@@ -925,14 +942,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);
@@ -1047,9 +1070,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
@@ -1199,14 +1276,43 @@ const Render = {
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
// Name them. "Somebody's rebuy wasn't recorded" is true and useless; the
// dealer's next move is to go to a particular seat, and the tool knows
// which. The commonest cause by far is the table's own starting stack:
// seats holding chips that were never bought, because "everyone starts
// with 200" is how a night opens.
const owing = HoldemState.seatsOwingCash(this.state);
const names = owing.map(row => row.name);
const who = names.length === 1
? names[0]
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
verdict.textContent = owing.length > 0
? `${Money.format(bank.difference)} more in chips than cash taken. ${who} ` +
`${owing.length === 1 ? "is" : "are"} holding chips with no buy-in written down.`
: `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
} else {
verdict.textContent =
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
}
verdict.classList.toggle("bankBalanced", bank.balanced);
verdict.classList.toggle("bankOff", !bank.balanced);
// One tap for the seats that are only holding the table's starting stack —
// which is the dealer's own instruction, said once in the setup panel, rather
// than the tool inventing cash. Anybody else's unrecorded rebuy is named in the
// sentence above and left to them, because only they know what it was.
const record = document.getElementById("bankRecordBuyInsBtn");
if (record) {
const starting = HoldemState.seatsOwingCash(this.state).filter(row => row.fromStartingStack);
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
record.hidden = starting.length === 0;
if (starting.length > 0) {
record.textContent = `Take ${Money.format(total)} from ${starting.length} ${starting.length === 1 ? "player" : "players"}`;
record.title = starting.length === 1
? `Write down ${Money.format(total)} taken from ${starting[0].name}`
: `Write down the starting stack as cash taken from ${starting.map(row => row.name).join(", ")}`;
}
}
},
// ---- Hand counter + history ----
@@ -47,10 +47,24 @@ const SeatMenu = {
run: () => this.hooks.toggleDealtIn(index),
});
// An empty chair with people waiting: the dealer taps the chair somebody is
// about to sit in, which is what their hand is doing anyway. Only the front of
// the queue is offered, for the same reason the panel only offers them — a list
// that isn't kept in order is just a pile of names.
const waiting = (this.state.waitingList || [])[0];
const emptyChair = !HoldemState.seatInPlay(player);
if (waiting && emptyChair) {
actions.push({
label: `Seat ${waiting.name}`,
className: "menuAccent",
run: () => this.hooks.seatWaiting(waiting.id, index),
});
}
// Leaving for the night, which is a different thing from sitting out a hand:
// the chips go back over the rail and the chair opens. Offered only for a seat
// that has somebody in it, since an empty chair has nobody to pay.
if ((player.name || "").trim() || Number(player.buyIns) > 0 || Number(player.stack) > 0) {
if (HoldemState.seatInPlay(player)) {
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
+134 -3
View File
@@ -115,6 +115,10 @@ const HoldemState = {
// out, and both halves have to stay on the record or the bank check reads
// as a shortfall for the rest of the evening.
cashedOut: [],
// People standing behind the chairs. A dealer at a full table keeps this
// list on a napkin; keeping it here means the tool can offer the next seat
// that opens to whoever has been waiting longest.
waitingList: [],
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
@@ -233,6 +237,16 @@ const HoldemState = {
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
parsed.waitingList = Array.isArray(parsed.waitingList)
? parsed.waitingList
.map(row => (typeof row === "string" ? { id: null, name: row } : row))
.filter(row => row && String(row.name || "").trim())
.map(row => ({
id: row.id || this.makeId("waiting"),
name: String(row.name).trim().slice(0, 24),
addedAt: Number(row.addedAt) || 0,
}))
: [];
parsed.cashedOut = Array.isArray(parsed.cashedOut)
? parsed.cashedOut
.filter(row => row && row.id)
@@ -789,7 +803,7 @@ const HoldemState = {
if (totals.has(player.id)) return;
const hasMoneyInPlay =
(Number(player.buyIns) || 0) > 0 ||
(Number(player.stack) || 0) > 0 ||
this.chipsHeldBy(state, player) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
(Number(player.committed) || 0) > 0;
if (!hasMoneyInPlay) return;
@@ -807,7 +821,7 @@ const HoldemState = {
// the way out. It is no longer on the table, so it is marked as paid
// rather than counted again at the end of the night.
const chipsNow = seat
? Money.round(Number(seat.stack) || 0)
? this.chipsHeldBy(state, seat)
: (gone ? gone.chips : 0);
// Chips already pushed into the hand in progress are still this
// player's money until it's won. Leaving them out makes the cash
@@ -875,6 +889,58 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
// ---- The people standing behind the chairs ----
//
// A full home game always has a couple of them, and the list lives on a napkin or
// in the dealer's head — which is where the argument comes from an hour later about
// who said they were next. Kept in order of arrival, because that is the only
// ordering anybody has ever agreed to.
addToWaitingList(state, name) {
const trimmed = String(name || "").trim().slice(0, 24);
if (!trimmed) return null;
if (!Array.isArray(state.waitingList)) state.waitingList = [];
const entry = { id: this.makeId("waiting"), name: trimmed, addedAt: Date.now() };
state.waitingList.push(entry);
return entry;
},
removeFromWaitingList(state, id) {
if (!Array.isArray(state.waitingList)) return null;
const index = state.waitingList.findIndex(row => row.id === id);
if (index < 0) return null;
return state.waitingList.splice(index, 1)[0];
},
// Chairs with nobody in them, in seat order. What "a seat has opened" means, and
// the same question the deal prompt answers when it counts the players.
openSeats(state) {
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => !this.seatInPlay(player))
.map(({ index }) => index);
},
// Sit the next person down. Their name goes on the chair and nothing else does:
// they haven't bought in yet, and guessing a stack for them would put chips on the
// table that nobody has paid for — which is the one thing the bank check exists to
// catch.
seatFromWaitingList(state, id, index) {
const seat = state.seats[index];
if (!seat) return { ok: false, reason: "There's no such seat." };
if (this.seatInPlay(seat)) {
return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
}
const entry = this.removeFromWaitingList(state, id);
if (!entry) return { ok: false, reason: "They aren't on the list any more." };
seat.name = entry.name;
// Not dealt in and holding nothing: the next thing that happens is the dealer
// taking their money, and Add chips is what says so.
seat.inHand = false;
return { ok: true, name: entry.name, index };
},
// ---- Somebody leaving with their chips ----
//
// The most ordinary thing that happens at a home game after midnight, and the tool
@@ -1014,6 +1080,71 @@ const HoldemState = {
};
},
// Is anybody actually in this chair? A seat with a name on it, chips in front of
// it, cash against it or a hand dealt to it. Empty chairs are not players, which
// matters the moment the table's starting stack starts standing in for a seat that
// has never been given one — every empty chair would otherwise look like somebody
// holding two hundred dollars.
seatInPlay(player) {
if (!player) return false;
return !!(player.name || "").trim() ||
(Number(player.buyIns) || 0) > 0 ||
(Number(player.stack) || 0) > 0 ||
(Number(player.committed) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
player.inHand === true;
},
// What a seat is holding, the way the felt and the betting see it — which includes
// the table's starting stack standing in for a seat that has never been given one.
//
// This is the number the money side of the tool was missing. "Starting stack" is
// offered in the setup panel as the ordinary way to open a table, and everything a
// dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
// check and the standings read `seat.stack` directly instead, which is null for
// exactly those seats: measured on a three-handed table with a $200 starting stack
// and nobody's own stack set, the felt played for $600 while the bank check said
// "no chips in play, balanced" and the standings listed nobody at all.
chipsHeldBy(state, player) {
if (!this.seatInPlay(player)) return 0;
return Math.max(0, Money.round(Betting.stackOf(state, player)));
},
// Seats playing with chips the box has no cash against.
//
// Two kinds, and they are not the same problem. A seat holding the table's starting
// stack has never been given chips of its own — the dealer set "everyone starts
// with 200" and sat people down, which is the ordinary way to open a table and the
// reason this whole gap existed. A seat with its own stack and no buy-in is a
// rebuy somebody forgot to write down, and only the dealer knows what it was.
seatsOwingCash(state) {
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
!((Number(player.buyIns) || 0) > 0))
.map(({ player, index }) => ({
index,
name: this.displayName(player, index),
chips: this.chipsHeldBy(state, player),
// True when they are holding the table's starting stack rather than one
// the dealer typed for them.
fromStartingStack: player.stack === null || player.stack === undefined ||
String(player.stack).trim() === "",
}));
},
// Write down the cash for the seats that are holding the table's starting stack.
// Only those: what somebody else's unrecorded rebuy was is a question only the
// dealer can answer, and guessing it would be the tool inventing money.
recordStartingBuyIns(state) {
const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
return {
seats: owed.length,
total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
};
},
bankReconciliation(state) {
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
// Cash the box took from people who have since left counts as taken: it is in
@@ -1024,7 +1155,7 @@ const HoldemState = {
const chipsInPlay = state.seats.reduce(
(sum, p) =>
sum +
Math.max(0, Number(p.stack) || 0) +
this.chipsHeldBy(state, p) +
Math.max(0, Number(p.committed) || 0) +
Math.max(0, Number(p.amountOverride) || 0),
0
+128
View File
@@ -0,0 +1,128 @@
// Saying who the blind is, before saying "cards".
//
// A dealer sets the table up out loud — button here, she's the small blind, he's the
// big blind — and only then deals. The seat menu offers "Make small blind" for exactly
// that, and it was answering by dealing the hand: betting opened, two stacks docked,
// the action bar up and waiting on the seat after the big blind. The dealer had said
// where the blinds go, and the tool heard "deal".
//
// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in
// this hand except the blinds" — true of a hand one second old, and equally true of a
// table in setup with no cards out and nothing committed. Those two need telling apart,
// which is what `blindsArePosted` is for.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
describe("before the deal", () => {
test("the blinds are not down, however much the table looks ready", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
assert.equal(state.bettingOpen, false);
// The old condition, on its own, says yes here — this is the whole bug.
assert.equal(Betting.onlyBlindsIn(state), true);
assert.equal(Betting.blindsArePosted(state), false);
});
test("a table straight out of setup, with nobody seated, is the same answer", () => {
assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false);
});
test("re-posting would have dealt the hand, which is why it is guarded", () => {
// Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in
// setup is the deal, and nothing about naming a blind should reach it.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
Betting.repostBlinds(state);
assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up");
assert.notEqual(state.actingSeatIndex, null);
});
test("naming a blind moves a badge and no chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
// What the seat menu does: pin the override, repost only if the blinds are down.
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt");
assert.equal(state.actingSeatIndex, null, "nobody is being asked to act");
assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]);
assert.equal(chipsOnTable(state), before);
// And the badges say what the dealer just said.
const blinds = HoldemState.getBlindSeats(state);
assert.equal(blinds.sbIndex, 3);
assert.equal(blinds.bbIndex, 0);
});
test("and the hand that follows posts from where they were aimed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
});
test("giving the button away is a marker moving too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
const willRepost = Betting.blindsArePosted(state);
state.dealerSeatIndex = 2;
if (willRepost) Betting.repostBlinds(state);
assert.equal(willRepost, false);
assert.equal(state.bettingOpen, false);
assert.equal(chipsOnTable(state), before);
});
});
describe("once the hand is dealt", () => {
test("the blinds are down, and re-aiming them moves real chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(Betting.blindsArePosted(state), true);
assert.equal(Betting.committed(state.seats[1]), 1);
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back");
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle");
});
test("but not once somebody has answered them", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, state.actingSeatIndex);
assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now");
});
test("nor on a later street", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
state.street = "flop";
assert.equal(Betting.blindsArePosted(state), false);
});
test("and a misdealt hand goes back to being a table in setup", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.takeHandBack(state);
assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again");
});
test("as does a hand that has been paid out", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.sweepToHandTotals(state);
HoldemState.startNextHand(state);
assert.equal(Betting.blindsArePosted(state), false);
});
});
+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);
});
});
+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");
});
});
+60
View File
@@ -719,6 +719,64 @@ const LayoutAudit = {
return { name: "the felt shows one dealer button", offenders };
},
// Anything that says it is hidden and is drawn anyway.
//
// `hidden` gets its `display: none` from the browser's own stylesheet, the weakest
// rule in the cascade, so any class that sets a display beats it — and in this tool
// every button class does. The result is an element the code believes is gone,
// sitting on the screen: found as a 21x44 empty box under the bank check, and this
// file's own touch check had been calling it "could not be probed" rather than
// saying the plainer thing, which is that it should not be there at all.
hiddenButPainted() {
const offenders = [...document.querySelectorAll("[hidden]")]
.filter(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const style = getComputedStyle(el);
return style.display !== "none" && style.visibility !== "hidden";
})
.map(el => `${this.describe(el)} :: hidden, but drawn ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`);
return { name: "hidden means hidden", offenders };
},
// A seat's name, painted under the decoration on its own puck.
//
// The chip that marks the button sits in the puck's top-right corner on a layer
// above the text, and the name is centred across the whole width. A long one
// therefore ran underneath it and stopped — measured, "Christopher" on the button
// lost 20px of its 64px box and showed no ellipsis, because as far as the text was
// concerned it had all the room it needed. Nothing else here would have caught it:
// the occlusion check is about controls a thumb has to reach, and this is a label
// a dealer has to read.
seatLabelsClear() {
const offenders = [];
document.querySelectorAll(".seat").forEach(seat => {
const name = seat.querySelector(".seatPuckName");
if (!name || !name.textContent.trim()) return;
const box = name.getBoundingClientRect();
if (!box.width || !box.height) return;
// Padding is room the text has already been kept out of, so it is not
// somewhere the text can be covered.
const style = getComputedStyle(name);
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
const textRight = box.right - parseFloat(style.paddingRight || 0);
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
const over = mark.getBoundingClientRect();
if (!over.width || !over.height) return;
const marked = getComputedStyle(mark);
if (marked.display === "none" || marked.visibility === "hidden" ||
parseFloat(marked.opacity) < 0.05) return;
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
if (covered > 1 && vertical > 1) {
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
}
});
});
return { name: "seat names aren't painted under their own badges", offenders };
},
// ---- Runner ----
// Every check this file knows how to make, every time.
@@ -747,6 +805,8 @@ const LayoutAudit = {
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
this.hiddenButPainted(),
this.seatLabelsClear(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];
+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");
});
});