Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 1689ecb4f0 Count the chips the table is actually playing with
"Starting stack" is how a night opens. The setup panel offers it and says what it
means — "seats without their own stack start here" — and everything a dealer
looks at reads it: the puck, the action bar, what a player is allowed to bet.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 05:40:06 -06:00
jheapsandClaude Opus 5 c19575ab10 Count the house's cut instead of calling it missing chips
A game that takes a rake has chips leaving the table every hand, and the bank
check had no idea. It added up the buy-ins, added up the chips on the felt, and
told the dealer somebody had been paid for chips they were never given — a
shortfall that grows all night and points at a miscount that never happened.
Every home game that takes a drop for the beer fund hits this on hand one.

So the tool takes it, rather than watching it go. Under Table Setup: a
percentage, an optional cap, and "no flop, no drop" — the rule almost everyone
plays, on by default, where a hand that ends before the flop pays nothing. Zero
percent is a game that doesn't rake, which is most of them and the default, and
nothing about that game changes.

Where it shows up is the point. The pot wizard says "House takes $2 of $40 — 5%,
capped at $5" above the payouts, before a chip moves, because the summary is the
dealer's last chance to disagree with the arithmetic and money taken quietly is
the one thing a tool like this must never do. The bank check gets a House take
line and says "balanced ... once the $2 in the house's cut is counted". The hand
log keeps the pot as the table saw it with the cut recorded beside it, and the
export has a column for it, so the night's total is one sum in a spreadsheet.

Two decisions worth stating. The rake rounds *down* to a chip the table actually
has: a house that can't make 40c takes nothing and the players keep it, which is
the only way to be wrong about this that nobody argues with. And on a split pot
it comes off the top, then what is left is shared in proportion — with the
rounding leftovers going back to the winners rather than to the house, which
asked for a number and gets that number, not that number plus dust.

Measured end to end through the UI: 5% capped at $5 on a $40 pot pays $38 and
banks $2; four stacks plus the cut come to exactly the $800 bought in; the bank
check reads balanced. With no-flop-no-drop on, a hand folded round pre-flop pays
the full $15 and takes nothing; with it off the same hand pays $14 and takes $1.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 05:12:33 -06:00
jheapsandClaude Opus 5 8aa90e90a4 Give a phone on its side the table shape it needs
The last two rounds left one thing failing and said so: a landscape phone with
six or twelve seats stacked two of them 25px apart at each end of the felt, with
pucks 64px tall. It has been there since seats were first spaced by distance
rather than by angle. Three things were wrong at once, and it needed all three.

The rim is the wrong shape when the table is very flat. An ellipse 616px wide and
96px tall has nowhere to put the seats near its ends: the curve doubles back on
itself, so two seats a long walk apart come out one above the other. Past two and
a half times wider than tall the rim is now a stadium — two straight rails with a
half-round cap at each end, which is how a real poker table is built and exactly
why it is built that way. A cap is a half-circle of the felt's own height, so an
end takes one seat and the rest is rail. Only past that ratio: measured, the
cut-off lands between a landscape phone at 4.4 and every other layout the tool
has, 1.45 at 1280x900 and 1.0 to 1.4 upright, so no working table changes shape.

The ring had less height than the felt did. The seat layer is inset by half a
puck to keep the outermost seats on screen, and 26px off the top and bottom of a
96px felt left 44px to work with — less than half the table, which is why no rim
shape could have fixed it alone. The inset is a percentage where the felt is
short. The landscape branch never had that rule at all, because it is matched on
height while the inset lives in a width query, so an 844px-wide phone on its side
drew its ring against the felt's whole box; with a flat-topped stadium rail that
put a dealer button over Resolve Pot in the header.

And the puck was a line too tall. In the tight state the hole cards give up their
row: of the four things on a puck, the cards are the one the dealer is holding in
their other hand, and the seat editor still shows them. That took the puck from
64px to 55px, which is what finally cleared the ends.

Measured across 36 combinations of nine viewports and 2, 6, 9 and 12 seats: the
closest two seats at 640x360 go from 25px apart to 55px, and every combination is
clean. Then 216 audits across thirteen viewports, three seat counts and both a
mouse and a thumb: no failures, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:58:59 -06:00
jheapsandClaude Opus 5 dc5db16ffd Let the phone table use the width it has
Last round capped the felt so the ring would clear the action bar, and capped the
wrong dimension. The wide layout sizes the felt from its width, so capping the
width is how you shorten it there — on a phone the felt is already the full width
of the column, and capping that threw away 96px of a 360px screen: a 240x240
table inside a 336px space, with nine pucks crowding it.

A max-height instead leaves the width alone and lets the aspect ratio give way,
which turns a short screen into a wide oval — the shape a poker table is. At
360x640: 336x240 rather than 240x240, same clearance over the bar, and the
twelve-seat ring that was crowded at 240 comes out clean, which closes the one
combination left failing last round. Where the room isn't the binding constraint,
390x844 and up, nothing changes and the table stays round. The landscape branch
now says max-height: none, because it already spends the same measured room on
its own height and applying it twice took the felt to the floor value.

The measurement that decides whether pucks shrink was also wrong, in a way that
only showed up once it was asked a hard question. Rim length per seat measures
the walk between two seats, not the gap between them, and on a long oval the rim
doubles back at the ends: twelve seats at 640x360 had 193px of rim each and sat
60px apart. It now measures the closest two neighbours directly, off the ring
itself — and off the seat layer rather than the felt, because the layer is inset
by half a puck on a phone and the felt's box put the answer out by 30px.

That makes the rule geometric rather than about screen size, so it is no longer
scoped to a phone media query: a wide landscape felt crowds at its ends while its
rim claims there is room, and now it is told so.

Pucks still touch at nine and twelve seats on a small screen. Tried 46px against
58px: on a roomy phone 46 cleared every overlap where 58 left two, but in a hand
with a side pot, where the bar has grown and the felt has shrunk to suit, 46
overlapped four times and 58 twice. That is the felt being small, not the puck
being wide, and paying for it with an ellipsis on every long name buys nothing.
What has to hold is that each seat keeps its own centre, and that is measured at
every size.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:33:25 -06:00
jheapsandClaude Opus 5 b9b2237dbe Run the two checks the audit had and never used
runAll() did not include the contrast check — it simply wasn't in the list — and
touch targets sat behind an opt-in flag nothing passed. So "the audit is clean"
has meant nine checks rather than eleven for a long run of rounds, and the two
left out are the two whose faults a dealer meets in a dim room with their thumb.
Both were written, tested, and then never called; the contrast checker was built
across two rounds and every sweep since has been silent about colour.

The standalone runner had a second version of the same hole: the 44px rule
stands itself down where `hover: hover` says there's a pointer, and a headless
desktop browser is a pointer, so a single pass had never measured a touch target
at any viewport. It now runs every viewport twice, once with a mouse and once
with a thumb.

A check that decides it doesn't apply now says so, rather than reporting an empty
offenders list — which reads exactly like a pass.

Faded text is measured instead of filed as unchecked. `opacity` on a box fades
the box and its text together onto whatever is behind it, so a dimmed seat is not
grey text on a dark puck: it is a faded puck and faded text both landing on the
page. Both halves are composited that way now. Two exemptions remain and both say
so out loud: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.

What running them found, and this round fixes:

  - the chair number on every seat: #8a8a8a at 9px, 3.39:1 once the empty seat's
    fade is composited, on the label that answers "which seat is that?";
  - Quick Split's amount field at 37px tall and its delete button at 30px across,
    on a page whose targets had never been measured;
  - and on a 360x640 phone with the bar grown by a side pot, the bottom seat
    sitting 38px inside it.

That last one brings back a cap this project measured and rejected once: capping
the felt to the room left fixed the six-seat ring and broke the nine-seat one.
That trade was real then and isn't now — seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
across 30 combinations of five phone sizes and 2 to 12 seats: every one clean
except twelve seats at 360x640, where a 240px felt and a 64px puck cannot both be
true. Where the rim can't give each seat a puck's width, the pucks come down a
size, measured from the rim itself rather than from a media query.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:02:43 -06:00
jheapsandClaude Opus 5 fc866597eb Deal the straddle, instead of leaving the dealer to fake it
A straddle is a blind raise put out before the cards by the player left of the
big blind, for twice it. Home games run them constantly, and the tool had no
idea they existed — so a dealer's only way through was to deal the hand and have
the straddler "raise" to $4, which gets the money right and the other two rules
wrong.

There are three at once:

  - the amount doubles as it goes left, and re-straddles carry on doubling;
  - first to act is left of the *straddler*, not left of the big blind, and the
    straddler keeps the option when it comes back round even if nobody raised;
  - the minimum raise is twice the straddle. Measured off the big blind
    underneath it, a $2/$4 game prices the next raise at $6, and every player at
    the table will argue — correctly.

Tap Straddle on the deal prompt and the tool posts it, puts the right player on
the clock and prices the raise. The seat wears an STR badge for the hand, read
off what the deal actually posted rather than off the count, so the badge can't
outlive the chips it stands for. It's a one-hand decision, like posting in, and
it clears when the button moves.

The control sits on the deal row rather than a line of its own: measured at
390x844, its own line cost 44px of felt on every deal prompt, straddle or no
straddle, and this bar is docked over the seats. Three buttons across a 360px
phone want to wrap, which made the bar 64px taller and put the bottom seat three
fifths behind it — this layout deliberately doesn't shrink the felt to suit, so
the deal button is given a 7rem floor instead, which keeps the row to one line
and the cost to nothing.

20 new tests, checked by breaking each of the three rules on purpose first: the
wrong first-to-act, the minimum measured off the big blind, and a flat rather
than doubling chain each fail a different one.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 03:32:17 -06:00
jheapsandClaude Opus 5 b97453fba1 Seat the table properly on a phone held sideways
Landscape is the one shape this layout has never really fitted, and the audit
was calling two separate faults there.

The felt worked its own height out as `100vh - bar - 7rem`, guessing that
everything above it came to 7rem. That is 108px at 640x360, which the guess
covers, and 119px at 844x390, which it doesn't — so on an iPhone on its side the
bottom row of seats sat up to 26px inside the action bar. The measurement it
needed was already being published for the wide layout: the bar's real height,
the wrap's real offset and the bottom seat's overhang, all taken off the window.
It uses that now. The floor under it drops from 220px to 96px, because 220 was
not a size a 390px-tall window has to give — holding out for it was itself how
seats ended up behind the Fold button, which is the one thing the floor exists
to prevent.

That still left the header taking two rows, and the second row is 44px of the
390. The title block and the five buttons came to 829px inside a 765px header,
missing one line by 64px. The page title goes — the one thing on this screen a
dealer already knows — hidden from the eye rather than removed, so the heading
is still there for a screen reader. The buttons tighten across but never down:
they stay a 44px thumb target.

The seats themselves were spaced by angle, which is only even spacing on a
circle. On a 601x126 felt, ten seats at 36° apart put four of them down each
steep end, 20px apart, with pucks 72px tall — a seat's centre belonged to its
neighbour, so tapping a player got the wrong one. They now walk the rim by
distance instead, which puts them along the two long rails the way a real table
seats people. Closest neighbours go from 33px apart to 59px.

Measured at 640x360, 667x375 and 844x390 with 9-, 10- and 12-seat tables: every
combination the audit used to fail is clean, bar a 10-seat table at 640x360,
where two seats at the end of a rim that flat still overlap. Portrait, tablet
and desktop are unchanged — the floor never bound there, and on a rounder felt
walking the rim gives back the same angles it always did.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 03:13:00 -06:00
jheapsandClaude Opus 5 419852a1f4 Let a dealer take the table off the device it's stuck on
The night lives in one browser's localStorage and nowhere else. A dealer whose phone
dies, or who wants to carry on from the tablet after the break, loses the stacks, the
buy-ins, where the button is and the whole hand log. The CSV export can't bring any of
it back: it records what happened, not where the table stands.

The table can now be written to a file and read back. Round-tripped through the real
UI, a hand in progress comes back mid-hand — blinds posted, the same seat on the
clock, the button where it was.

Loading replaces everything, so it asks first and names both sides rather than saying
"are you sure": how many players are in the file and when it was saved, against how
many players and how many logged hands are about to go. It goes through the same
undoable path as the header buttons, so the toast offers the way back, and the
confirm says so instead of overstating it.

Reading a file goes through exactly the same migration a save from storage does —
load() had that logic inline and it is now a function both call. A second, looser path
for imports is how a format grows two meanings of the same field.

What it refuses, each with something a dealer can act on and never a half-loaded
table: anything that isn't JSON, JSON with no seats in it, a save from some other tool
that happens to carry a `kind`, and a file from a newer version — that last one
because loading it risks reading a field whose meaning has changed, and the dealer
would find out from the money. What it accepts anyway: a bare table with no envelope,
for somebody who copied the storage value out by hand.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 01:07:13 -06:00
jheapsandClaude Opus 5 8a2fc3e540 Give undo and the card picker's buttons a fingertip to land on
The first thing the repaired audit found, run on a real touch context at 44px: three
controls a dealer taps on a phone that are too small to tap.

    .actionUndo             145x30   the "I recorded that wrong" pill
    #cardPickerClearBtn      50x40   wipes a player's cards
    #cardPickerCloseBtn      50x40   closes the picker

Undo is the worst of them. It is the control a dealer reaches for having just entered
the wrong action with five people waiting, and it was fourteen pixels short of a
fingertip. The picker's two are short because the touch rule that fixes every other
small button is scoped to .panelBody, and the picker is a sheet — it never reached
them. One of them is destructive.

All three now sit at 44px on touch. Nothing changes on a pointer device.

I tried an invisible hit-area pad on undo first, to avoid spending bar height, and the
measurement said it did nothing: the pill clips its overflow to keep a long label on
one line, and giving it a positioning context made it clip the pad too. Growing the
box is the honest fix, and it turned out to cost nothing anyway — the bar goes from
138px to 152px and the felt stays at 366px, because the felt had slack against its
220px floor rather than sitting on it as I had assumed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:55:56 -06:00
jheapsandClaude Opus 5 b64da7a787 Make the layout audit usable inside modals, and stop it passing what it can't measure
Last round turned up that Quick Split had never been audited. Chasing that, the reason
the dealer table's modals had never been audited either turns out to be the harness,
not neglect: it reported the pot wizard covering the seats as ten faults and the
rulings sheet as seven. A modal covering the page is what a modal is for, and an
instrument that calls that a fault is one nobody can use in those states.

It now finds whatever is open on top and scopes the reachability checks to it — the
overlays, plus the phone seat sheet, which is a body class rather than an element. The
seat-ownership check is skipped outright while a sheet is open: seats behind it aren't
fighting each other for taps, nothing is meant to reach them at all.

Two worse faults came out of proving the fix rather than assuming it. Injecting a 20x14
button into the open wizard, the contrast check caught it and the touch-target check
did not:

  * effectiveTarget returns null when elementFromPoint can't find the control, and the
    check treated that as a pass. Silent skips are how faults hide — the same lesson as
    the aria-hidden and faded-text exemptions before this. Unmeasurable controls are
    now reported as warnings.
  * The probe failed on anything scrolled out of its container. That was hiding a real
    control: "Confirm Winner(s)", the button that pays the pot out, had gone unmeasured
    for thirty-six rounds. It is scrolled into view before probing, and passes.

A checkbox inside a label is now measured by its label, since tapping the label is what
activates it. Without that, the dedicated-dealer toggle read as an unprobeable 24px
target when it has a 335x44 label around it.

The tool itself came out clean: no failures and no warnings across the table, the pot
wizard, the rulings sheet and the seat editor, on a real touch context at 44px. The
value here is that those states can be checked at all from now on — verified by
injection, which the fixed check catches and the old one did not.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:37:47 -06:00
jheapsandClaude Opus 5 3be93cdadf Chop a side pot on the screen that worked it out, and fix Quick Split's contrast
Quick Split lists the pots when somebody is all-in — main, sides, uncalled — and then
leaves the dealer there. Two players tying for a side pot is ordinary, and working out
each one's share meant switching to the other mode and retyping an amount the screen
had just calculated. Retyping a number with money on it is a transcription error
waiting to happen, and the number was already on screen.

Each pot with two or more people who can win it now carries a Chop, opening underneath
its own row: as many ways as there are contenders by default, a stepper for a chop
between fewer of them, and the same odd-chip and dust wording the main split uses,
because it is the same rule and the dealer shouldn't have to notice they are reading a
different screen. Per-row state, so opening the main pot's chop doesn't disturb what
the side pot below it says. Not offered on the uncalled row, which goes back to one
player, nor on the total.

Separately, this page had never been through the layout audit the dealer table gets
every round, and it was carrying nine contrast failures:

    .unevenCurrency   4.11:1   the $ beside every amount typed
    .unevenRemoveBtn  3.66:1   the ✕ that deletes a player's entry
    .splitFootnote    3.94:1   the instruction for what to do with the pot list

All under the 4.5:1 small-text minimum, the worst of them on a destructive control.
Lifted to pass. The large $ on the even-mode pot input is left alone: at 1.6rem it is
large text, where 3:1 is the bar and 4.11:1 clears it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:24:07 -06:00
jheapsandClaude Opus 5 40fa16920b Offer bet sizes the table's chips can actually make
Half the pot lands on a half chip constantly. Pre-flop at 1/2 the honest half-pot is
$4.50, and the smallest chip in the default legend is a dollar — the tool's own chip
breakdown calls it "4×$1, $0.50 no chip covers". The button offered it anyway, so the
dealer rounded it themselves every single time.

The pot-derived quick amounts now round up to something the table can build. Up, not
down or nearest: these are bet sizes, a pot-sized bet that has been shaved is no
longer a pot-sized bet, and rounding up cannot drop an amount below a legal minimum
the way rounding the other way can.

Only when the legend describes this game. A smallest chip larger than the big blind
means the denominations were never set for these stakes, and rounding a 5c/10c table
up to whole dollars would be the tool doing real damage on the strength of a field
somebody forgot to fill in. Those tables keep exact amounts, and the wrong legend
stays visible rather than being quietly applied. A table with no legend at all is
told nothing about chips and so has nothing applied either.

Verified across five tables: default dollar chips at 1/2 give $4/$5/$7, cent chips at
5c/10c keep $0.20/$0.23/$0.35, five-dollar chips at 5/10 give $20/$25/$35, and both
the stale-legend and no-legend tables are left exact.

The minimum raise is deliberately not rounded. It is what the rules require, and
moving it to suit the chip tray would change what is legal rather than what is
convenient.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:05:26 -06:00
jheapsandClaude Opus 5 09eac536b5 Make the pot button offer a pot-sized raise
The raise pad worked its quick amounts out as "the current bet plus the pot as it
stands". That leaves out the raiser's own call, which is in the pot before the raise
is measured off it, so every amount came out short. Measured pre-flop at 1/2:

    offered:  Min $4    ½ pot $4    Pot $5
    correct:  Min $4    ½ pot $4.50  Pot $7

A pot raise there is: call the $2, which makes the pot $5, then raise $5. And the
half-pot was so short it clamped up to the minimum and sat next to the Min button
showing the same $4 twice, which is how it went unnoticed — it looked like a table
where those two happened to coincide.

"Pot" is not a suggestion. It is a definite number players say out loud, and a dealer
tapping the button that claims to be it should be recording what was actually said.

The rule moves into Betting.potRaiseTo, next to the other rules and testable on its
own, rather than living as arithmetic inside a DOM renderer:

    raise to = the current bet + (pot + what they owe) × fraction

Facing no bet it comes out as a plain pot-sized bet, because both the bet and the
amount owed are zero. Facing a bet it comes out as three times the bet plus the dead
money, which is the shape a player checks it against.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 23:51:41 -06:00
jheapsandClaude Opus 5 8efc14f61f Show the banker the amount to count out, not just whether the player is up
The standings had a column headed "Cash out" and it did not hold an amount to pay.
It held the difference against the player's buy-in. Measured on a table where Ann
bought in for $200 and sits behind $340:

    PLAYER   NET   CASH OUT
    Ann      $0     +$140

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

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

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

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

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

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

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

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

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

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

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

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

Three things were wrong and each hid the next.

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 23:08:42 -06:00
jheapsandClaude Opus 5 9f3d0dce4b Call who shows first at the showdown, instead of leaving the dealer to remember
Every showdown that isn't a fold needs somebody to turn over first, and it is the one
call at the table nobody volunteers for — everyone would rather see a hand than show
one. The tool held both facts the rule needs, who last raised and who sits left of
the button, and said neither. The dealer worked it out from memory while five people
waited, every hand.

The action bar now names the seat and the reason above the resolve button:

    Dee shows first — last to bet or raise.
    Ben shows first — checked round, first left of the button.
    Dee shows first — last to put money in; nobody could bet the river.

The reason is there so the call can be repeated to whoever argues with it, which is
half of what makes it stick.

Three cases, in the order the rule asks them. Somebody bet or raised on the final
round: they show. Nobody did: first live player left of the button. Nobody *could*,
because the chips were already in: it falls to whoever last put money in, on whichever
street that was — "no bet on the final round" isn't a choice anybody made when there
was no round to bet in, so the seat left of the button hasn't earned it.

Aggression is tracked per street and per hand, because those two answers differ
exactly when it matters. A short all-in counts: not reopening the betting is a rule
about who may raise, and says nothing about who shows. Posting a blind doesn't — a
hand limped and checked all the way down has no aggressor at all, which is the case
that goes to the button.

A matching ruling goes in Common rulings, since the dealer will be asked.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:53:14 -06:00
jheapsandClaude Opus 5 01f5139740 Let the dealer find one hand in the log instead of scrolling the night
The hand log exists for one moment: somebody at the rail says they won a pot an hour
ago and wants it looked up. A night runs to sixty hands or more and the panel showed
five of them at a time, in a box fixed at 260px whether the dealer was on a phone or
a 27" screen with room for twenty. There was no way to ask for a hand — only to
scroll and read every row.

A filter box over the list now takes a name, an amount or a hand number. Matching is
loose on purpose: "fay" whatever case it's typed in, "190" for a $190 pot or a $190
payout, "$1,90" for the same typed badly, "#12" when the dealer knows the hand and
wants that exact one. Beside it, how much of the night is hidden — "8 of 60" — since
a filter matching one hand otherwise looks like a table that only played one.

The list also takes the room it has: clamp(200px, 42vh, 560px) rather than a flat
260, which is seven or eight rows instead of five on a desktop and no worse on a
phone.

Export stays the whole night, always. A CSV quietly missing the hands scrolled past
would be a record with holes in it and nothing on the file to say so — if a filter is
up when it's exported, the toast says how many went in.

Matching deliberately covers only what the row displays. Searching the full ledger as
well was the first attempt, so the hand that broke somebody could be found under the
loser's name — but the ledger holds everyone who put a chip in, and measured on a
seven-handed night where nobody folded, "Fay" returned 60 of 60. A filter that
searches text the panel doesn't show looks broken even when it's right.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:39:55 -06:00
jheapsandClaude Opus 5 c76ebf4d18 Say when somebody is being dealt in for free, and give the dealer the post
Sitting down between the button and the big blind means the blinds have gone past
you. The tool dealt those players straight in, silently. Measured: Eve takes a seat,
and the next hand she is dealt cards in late position having put nothing in — a free
hand, out of everyone else's night a couple of dollars at a time, with nothing
anywhere on screen to tell the dealer it had happened.

At a table there are two answers and the player picks one: post the big blind and
come in now, or wait for the blind to reach them. The tool did neither and named
neither.

The deal prompt now names anyone about to be dealt in who has not paid for it, and
carries the post as one tap. Nothing is forced — a home game that doesn't care can
deal as before, and the sentence is still worth having on its own, because the
dealer at least knows. The post goes in as a big blind out of that player's own
stack, is live like any blind, and is spent at the end of the hand rather than
becoming a standing charge.

Somebody back from sitting out is owed one the same as a newcomer. The big blind of
the hand isn't — they're already paying — and neither is anyone at a table that has
not dealt a hand yet, where the whole table is new and nobody has been skipped.

Deliberately one line for however many have walked up, not one each: the bar is
docked over the felt and the felt has already shrunk as far as it goes on a phone,
so every line costs seat area. A line apiece put the bar over two fifths of the
bottom seat at 390x844.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:27:22 -06:00
jheapsandClaude Opus 5 cc060b29b8 Keep the big blind moving one player at a time when somebody leaves out of the blinds
Passing the button to the next player is right until a chair between the button and
the blinds empties. Then it pulls the whole blind order forward a place. Measured,
four-handed, button on Ben, blinds Cal and Dee, with Cal cashing out:

    tool:    button Dee, small blind Ann, big blind Ben
    correct: dead button on Cal's chair, small blind Dee, big blind Ann

Ann is never asked for the big blind she owes, and Ben pays one a full orbit early.
That is a real big blind out of a real stack, and it happens every time somebody
stands up out of the blinds — which in a cash game is constantly. It is also the
kind of thing the dealer gets blamed for, because the tool is the thing everyone at
the table is reading position off.

The dead button rule holds the rotation to the seats for one hand instead of to the
players. The button follows the small blind round, which at a table with nobody
missing is the same next-player-along it always was; when that player has gone the
button rests on their empty chair for a hand, and when the big blind has gone no
small blind is posted. Either way the big blind advances exactly one player, which
is the whole point of it.

Worked out from who actually posted last hand rather than from the seating as it
stands, because by the time the question is asked the people it turns on are the
ones who have left. Heads-up is exempt — the button is the small blind there and
there is no position to leave dead — as is a table too sparse to have a rotation to
hold to, and a dealer who moves the button or names a blind by hand overrules it.

Both dead positions are said out loud rather than left to look like a fault: the
deal prompt explains them in a sentence, and the dead button goes hollow and dashed
so the felt agrees with the words.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:04:57 -06:00
35 changed files with 6158 additions and 173 deletions
+116 -2
View File
@@ -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>
@@ -86,6 +87,32 @@
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>House take</summary>
<div class="subPanelBody">
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
</label>
</div>
<label class="fieldRow">
<span>No flop, no drop</span>
<input type="checkbox" id="rakeNoFlopInput" />
</label>
<p class="fieldHint">
Taken from the pot when it's awarded, and shown before anything is pushed.
Rounded down to a chip the table has, so the players keep what the house
can't make. Leave the percentage at zero for a game that doesn't rake.
</p>
<p class="fieldHint" id="rakeTakenHint" hidden></p>
</div>
</details>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
@@ -110,14 +137,23 @@
<span>Player</span>
<span class="standingsInHead">In</span>
<span class="standingsWonHead">Won</span>
<span>Net</span>
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
<span class="standingsNetHead">Net</span>
<span class="standingsChipsHead" title="What they are holding — the cash to count out if they leave now">Chips</span>
<span title="Those chips, less the cash they handed over">Up/down</span>
</div>
<div id="standingsList"></div>
</div>
</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>
@@ -131,7 +167,16 @@
<span>Taken off the table</span>
<span id="bankDiscarded"></span>
</div>
<div class="bankReconcileRow" id="bankRakedRow" hidden>
<span>House take</span>
<span id="bankRaked"></span>
</div>
<div class="bankReconcileRow" id="bankPaidOutRow" hidden>
<span>Paid out</span>
<span id="bankPaidOut"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
</div>
<div id="settleUp" hidden>
@@ -141,13 +186,30 @@
</div>
<h3 class="panelSubhead">Hands</h3>
<div class="handHistoryFilterRow">
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
<span id="handHistoryCount" class="handHistoryCount" hidden></span>
</div>
<div id="handHistoryList"></div>
<p id="handHistoryNoMatch" class="fieldHint" hidden></p>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
<h3 class="panelSubhead">The table itself</h3>
<p class="fieldHint">
The night lives in this browser and nowhere else. Save it to a file to carry it
to another device, or to have something to come back to if this one dies.
</p>
<div class="handHistoryActions">
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
</div>
</div>
</details>
@@ -190,6 +252,7 @@
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<span class="blindBadge strBadge" title="Straddled — the action comes back to this seat">STR</span>
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
@@ -264,6 +327,7 @@
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsChips"></span>
<span class="standingsCashOut"></span>
</div>
</template>
@@ -341,6 +405,40 @@
</p>
</section>
<section class="ruling" data-short="Clock">
<h3>Calling the clock on a player</h3>
<p>
Any player may ask for a clock once a decision is taking unreasonably
long; it is the dealer, not the table, who grants it. The player then
gets a set time and a countdown out loud — thirty seconds and ten is the
usual form.
</p>
<p>
If the count reaches zero with no action, the hand is <em>dead</em> if
they were facing a bet, and <em>checked</em> if they were not: nobody is
folded for slowness when checking was free. The action bar runs the
count once you tap <em>Call the clock</em>, and offers the ruling as one
button when it ends.
</p>
</section>
<section class="ruling" data-short="Straddle">
<h3>A straddle is a blind raise</h3>
<p>
The player left of the big blind may put out twice it before the cards,
without looking. It is a blind bet, not a raise made in turn, so it does
three things at once: everyone else acts first, the minimum raise becomes
twice the straddle, and the straddler still has the option when the
action comes back — they may raise even if nobody raised into them.
</p>
<p>
Re-straddles carry on to the left, each doubling again, and stop before
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
posts it, puts the right player on the clock and prices the raise;
the seat wears an <em>STR</em> badge for the hand.
</p>
</section>
<section class="ruling" data-short="Short all-in">
<h3>A short all-in doesn't reopen the betting</h3>
<p>
@@ -393,6 +491,22 @@
</p>
</section>
<section class="ruling" data-short="Who shows first">
<h3>Who turns their hand over first</h3>
<p>
If there was a bet on the river, the last player to bet or raise shows
first. If the river was checked all the way round, it's the first live
player left of the button. Nobody has to be asked twice — a player may
always turn their hand over early if they want to.
</p>
<p>
When everyone is all-in before the river there was no final round to
lead, so it falls to whoever last put money in, on whichever street that
was. The action bar names the seat and the reason at every showdown, so
the call is already made before anyone asks.
</p>
</section>
<section class="ruling" data-short="Odd chip">
<h3>The odd chip in a split pot</h3>
<p>
+480 -27
View File
@@ -586,12 +586,16 @@ button,
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
}
/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: #8a8a8a
came out at 3.39:1 once the fade was composited, for the label a dealer reads to
answer "which seat is seat 6?". Lifted until it passes with a little to spare —
measured 4.5:1 at #a5a5a5 exactly, so it sits above that. */
.seatChairNumber {
position: absolute;
top: 2px;
left: 6px;
font-size: 0.58rem;
color: #8a8a8a;
color: #aeaeae;
}
.seatPuckName {
@@ -730,6 +734,18 @@ button,
display: none;
}
/* The dead button. It's on an empty chair on purpose, and that reads as a mistake —
a gold puck on nobody looks exactly like a button that has drifted and wants
dragging back. So it keeps the button's gold, to stay findable as the button, but
goes hollow and dashed: the same shape, visibly holding a place rather than
marking a player. The deal prompt says why in words; this is so the felt agrees
with it at a glance instead of contradicting it. */
.dealerChip.active.dealerChipDead {
background: rgba(245, 213, 71, 0.14);
border-style: dashed;
color: #f0d98c;
}
.blindBadge {
display: none;
position: absolute;
@@ -784,6 +800,19 @@ button,
color: #241a05;
}
/* The straddle. It takes the big blind's slot on the puck, which is free by
construction: the chain starts left of the big blind and stops short of the small
one, so no seat ever wears two of these.
Violet rather than a third shade of gold or blue, because the badge a dealer is
looking for mid-hand is the one telling them where the action comes back to.
Dark text on the light end of the ramp, measured 5.76:1 against the darker stop. */
.strBadge {
left: 22px;
background: linear-gradient(180deg, #cbaaf0, #a97fd6);
color: #1b1030;
}
/* A blind the dealer named by hand rather than one the button handed out. Without
the ring the table just looks like it's put the blinds in the wrong place. */
.blindBadge.blindManual {
@@ -791,6 +820,43 @@ button,
outline-offset: 1px;
}
/* Seats close enough together that full-size pucks would overlap. The class is set
from a measurement render.js takes off the ring itself — the closest two neighbours,
straight line — because no media query can see it: the same 390px phone is roomy at
six seats and crowded at nine, and a wide landscape felt crowds at its ends while
its rim says there is space. Not scoped to a screen size for the same reason. */
.seatsTight .seat {
/* 58 was chosen by measurement and 46 was tried against it. On a roomy phone 46
cleared every overlap at nine seats where 58 left two — but in the state that
actually matters, a hand with a side pot where the bar has grown and the felt has
shrunk to suit, 46 overlapped four times and 58 twice. Pucks touching at that
density is the felt being small, not the puck being wide, and paying for it with
an ellipsis on every long name buys nothing. What has to hold is that each seat
keeps its own centre, so a tap lands on the player it was aimed at, and that is
measured at every size. */
width: 58px;
min-height: 38px;
padding: 3px 4px;
}
.seatsTight .seatPuckName {
margin-top: 0.35rem;
font-size: 0.62rem;
}
.seatsTight .seatPuckStack {
font-size: 0.55rem;
}
/* The hole cards give up their line here. A puck this close to its neighbours is 64px
tall and the seats at the ends of a landscape felt are 55px apart, so the line has to
come from somewhere — and of the four things on a puck, the cards are the one the
dealer is holding in their other hand. Name, bet and stack stay. The seat editor
still shows the cards in full. */
.seatsTight .seatPuckCards {
display: none;
}
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
@@ -1804,31 +1870,44 @@ button,
white-space: nowrap;
}
/* The per-hand columns are detail: how the night's swings happened. They're the
first thing to go when there isn't room, because Net and Cash out are what the
banker is actually reading. */
/* The per-hand columns are detail: how the night's swings happened. They're the first
to go when there isn't room, and Net goes with them — it only moves when a pot is
resolved through the wizard, so on a table where the dealer settles pots by hand it
reads $0 all night beside two columns that don't.
What survives at every width is the pair the banker actually works from: what the
player is holding, and whether that leaves them up or down. */
.standingsIn,
.standingsWon,
.standingsNet,
.standingsInHead,
.standingsWonHead {
.standingsWonHead,
.standingsNetHead {
display: none;
}
@container (min-width: 30rem) {
.standingsRow {
grid-template-columns: minmax(5rem, 1fr) auto auto auto auto;
grid-template-columns: minmax(5rem, 1fr) auto auto auto auto auto;
}
.standingsIn,
.standingsWon,
.standingsNet,
.standingsInHead,
.standingsWonHead {
.standingsWonHead,
.standingsNetHead {
display: block;
}
}
/* The settle-up column is the one a banker reads while counting out cash, so it
carries the weight the per-hand columns don't. */
/* The chips column is the one a banker's hands follow while counting money onto the
table, so it carries the weight. Up/down is read once, at the end. */
.standingsChips {
font-weight: 600;
color: #e6e6e6;
}
.standingsCashOut {
font-weight: 600;
}
@@ -1945,11 +2024,62 @@ button,
color: #e07b7b;
}
/* The showdown call, sat above the resolve button. Loud enough to be read across a
table at a glance — this is something the dealer says out loud — but not so loud it
competes with the button underneath it. */
.showdownCall {
margin: 0 0 0.45rem;
text-align: center;
font-size: 0.82rem;
color: #f0d98c;
}
.handHistoryFilterRow {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.handHistoryFilter {
flex: 1 1 auto;
min-width: 0;
/* 16px or iOS zooms the whole page on focus, which on a phone at the table throws
the felt off screen mid-hand. Every other field in this panel is held at 16px
for the same reason. */
font-size: 16px;
padding: 0.35rem 0.5rem;
background: #141414;
border: 1px solid #2c2c2c;
border-radius: 6px;
color: #e6e6e6;
}
.handHistoryFilter::placeholder {
color: #8a8a8a;
}
.handHistoryFilter:focus-visible {
outline: 2px solid #d8b46a;
outline-offset: 1px;
}
.handHistoryCount {
flex: 0 0 auto;
font-size: 0.72rem;
color: #8a8a8a;
font-variant-numeric: tabular-nums;
}
#handHistoryList {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 260px;
/* Was a flat 260px, which is about four and a half rows — the same four and a half
whether the dealer is on a phone or a 27" screen with room for twenty. The floor
keeps it honest on a short window; the ceiling stops the panel from running away
on a tall one and pushing everything below it out of reach. */
max-height: clamp(200px, 42vh, 560px);
overflow-y: auto;
}
@@ -2124,6 +2254,144 @@ body.hasActionBar #holdemSidebar {
font-weight: 600;
}
/* Starting and stopping a clock. Quiet, because it only appears once somebody has
been thinking long enough for the table to ask.
44px like everything else a thumb has to find. It was drawn at 36 to keep the status
line short, and the audit called it at every touch viewport the moment it ran — which
is the rule doing its job: this control kills a hand, and the argument for shaving it
was that the row it sits in is made of text. */
.actionClockBtn {
flex: none;
min-height: 44px;
padding: 0.25rem 0.6rem;
border-radius: 8px;
border: 1px solid #5b4a2a;
background: rgba(216, 180, 106, 0.12);
color: #e8cf9a;
font-family: inherit;
font-size: 0.72rem;
cursor: pointer;
}
/* The count has run out. Said in the dealer's own words, above the button that
carries it out — and coloured like the thing it is, which is a hand about to be
killed for slow play. */
.clockExpired {
margin: 0 0 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
background: rgba(224, 123, 123, 0.12);
border: 1px solid rgba(224, 123, 123, 0.45);
color: #f3c9c9;
font-size: 0.8rem;
}
.actionBtn.actionEnforceClock {
background: linear-gradient(180deg, #6b2b2b, #4a1d1d);
border-color: #8c3a3a;
color: #ffe2e2;
font-weight: 600;
}
/* A player who has taken their chips and gone. Still on the list, because the box took
their cash and the night's record has to say so, but set apart so nobody counts money
out to them twice at 2am.
Set apart by a stripe rather than by fading the row, which is what this first did:
at 0.72 the contrast check measured the In and Won cells at 3.83:1, under AA, on the
list a dealer settles the night from. The row says "paid out" in words next to the
name — it doesn't need to be hard to read as well. */
.standingsRow.standingsGone {
background: rgba(255, 255, 255, 0.04);
border-left: 2px solid #4a4a4a;
padding-left: 0.35rem;
}
.standingsRow.standingsGone .standingsName {
font-style: italic;
}
/* The people standing behind the chairs. A short list in a panel that already has a
lot in it, so it is built from rows rather than a table: a name, the seat it can go
in, and a way off the list. */
.waitingAdd {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.waitingAdd input {
flex: 1;
min-width: 0;
min-height: 44px;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 8px;
color: #eee;
font-family: inherit;
/* 16px, or iOS zooms the whole page in when it takes focus. */
font-size: 16px;
padding: 0.35rem 0.6rem;
}
.waitingAdd .holdemBtn {
flex: none;
min-height: 44px;
}
.waitingList {
list-style: none;
counter-reset: waiting;
margin: 0 0 0.5rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.waitingRow {
display: flex;
align-items: center;
gap: 0.4rem;
counter-increment: waiting;
}
/* The position in the queue, which is the whole point of the list. */
.waitingRow::before {
content: counter(waiting) ".";
flex: none;
min-width: 1.2rem;
color: #9a9a9a;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.waitingName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #e8e8e8;
font-size: 0.85rem;
}
.waitingNote {
flex: none;
color: #9a9a9a;
font-size: 0.75rem;
}
.waitingRow .holdemBtn {
flex: none;
min-height: 44px;
}
.waitingDropBtn {
min-width: 44px;
}
/* ---------- Side-pot breakdown ---------- */
.potBreakdown {
@@ -2768,6 +3036,20 @@ body.hasActionBar #toastHost {
color: #ff6b6b;
}
/* What the house takes, above the payouts it comes out of. Stated in the summary
rather than only in the log afterwards: this is the dealer's last chance to
disagree with the arithmetic before the chips move, and money taken quietly is the
one thing a tool like this must never do. */
.potWizardRake {
margin: 0 0 0.7rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
background: rgba(216, 180, 106, 0.1);
border: 1px solid rgba(216, 180, 106, 0.35);
color: #e8cf9a;
font-size: 0.82rem;
}
.potWizardSummary ul {
list-style: none;
padding: 0;
@@ -2817,14 +3099,31 @@ body.hasActionBar #toastHost {
}
@media (max-width: 700px) {
/* The narrow layout already had an answer for the bar: it reserves the bar's height
as scroll room below the table, and a phone is a thing you scroll. Capping the
felt here as well was measured and rejected — at 360x640 it took the table from
321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
covered seats traded for two seats whose centre landed on a neighbour. The cap is
for the wide layout, which never consulted the bar's height at all. */
/* The cap lives here now, and the reason it didn't is worth keeping.
It was measured and rejected once: at 360x640 it took the table from 321px to
226px, which fixed the six-seat ring but broke the nine-seat one — two covered
seats traded for two seats whose centre landed on a neighbour. The narrow layout
fell back on reserving the bar's height as scroll room, on the grounds that a
phone is a thing you scroll.
That trade was real then and isn't now: seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
at 360x640 with the bar grown by a side pot, where the bottom seat sat 38px
inside it: the ring now clears the bar by 50. Scroll room is not an answer for a
seat that is covered rather than merely below the fold.
The room is spent on height, not on both. Capping the *width* by it — the wide
layout's way, because there the felt is sized from its width — kept the table
square and threw away 96px of a 360px screen: 240x240 inside a 336px column. A
max-height leaves the width alone and lets the aspect ratio give way, so a short
screen gets a wide oval, which is the shape a poker table is anyway. Measured at
360x640: 336x240 rather than 240x240, same clearance, and the twelve-seat ring
that was crowded at 240 comes out clean. Where the room isn't the binding
constraint — 390x844 and up — nothing changes and the table stays round. */
#pokerTableWrap {
max-width: none;
max-height: var(--feltRoomPx, 100vh);
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
@@ -2896,10 +3195,18 @@ body.hasActionBar #toastHost {
}
/* Keeps every puck inside the viewport: the ring is drawn within this box,
so half a puck of padding is exactly enough to stop the overhang. */
so half a puck of padding is exactly enough to stop the overhang.
Half a puck is the right answer when the felt is tall enough to spare it. On a
phone held sideways it isn't: the felt is 96px tall there once the action bar has
grown, and 26px off the top and bottom left the ring 44px of height to work with
— less than half the table. That is what kept the seats at the ends stacked no
matter what shape the rim was.
Above about 216px of felt the flat 26px still wins, so nothing upright changes. */
#seatsLayer,
#betsLayer {
inset: 26px 44px;
inset: min(26px, 12%) 44px;
}
/* Narrower than the desktop puck: at a full ten-handed table the ring is
@@ -3062,7 +3369,18 @@ body.hasActionBar #toastHost {
}
.panelBody .holdemBtnSmall,
.handHistoryActions .holdemBtn {
.handHistoryActions .holdemBtn,
/* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
.panelBody and the picker is a sheet, so it never reached them. Clear wipes a
player's cards, which is not a thing to make a dealer aim at. */
.cardPickerHeader .holdemBtn,
/* Undo was 30px, and it is the control a dealer reaches for having just recorded
the wrong thing with five people waiting — the worst place to be fourteen pixels
short of a fingertip. Growing the box costs bar height, which comes out of the
felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
is room for it. A hit-area pad was tried first and does not work here: the pill
clips its overflow to keep a long label on one line, which clips the pad too. */
.actionUndo {
min-height: 44px;
}
@@ -3160,28 +3478,79 @@ body.hasActionBar #toastHost {
why a seat's centre belonged to its neighbour.
Landscape has width and no height, so the table takes the width it has and
is only as tall as the room left over. */
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 7rem);
is only as tall as the room left over.
The height comes from --feltRoomPx, the same measured value the wide layout
caps its width by: the bar's real height, the wrap's real offset and the
bottom seat's overhang, all taken off the window. This branch used to work
it out itself as `100vh - bar - 7rem`, guessing that everything above the
felt came to 7rem. It comes to 108px at 640x360, which the guess covers, and
119px at 844x390, which it doesn't — so on an iPhone on its side the bottom
three seats sat up to 26px inside the action bar. Nothing here needs to
guess; the measurement was already being published. */
width: 100%;
height: var(--feltRoom);
min-height: 7rem;
height: var(--feltRoomPx, calc(100vh - var(--actionBarHeight, 0px) - 7rem));
/* This branch sets its own height outright, so neither the aspect ratio nor the
cap derived from it has anything to say here — and the cap would only narrow
a table that is already as short as the screen allows. */
aspect-ratio: auto;
max-width: none;
/* This branch already spends the measured room on its own height. The phone
block's max-height is the same number arriving twice, and applying it here
took the felt down to the floor value and crowded the ring — measured at
640x360, two seats lost their centres to their neighbours. */
max-height: none;
}
/* The page's own top padding is 32px of nothing, on the screen that has the least
to spare. */
#holdemApp {
padding-top: 0.5rem;
}
/* The header ate 148px of a 390px-tall window, which was most of the reason the
table started below the fold. Here it only needs to be a title bar. */
table started below the fold. Here it only needs to be a title bar.
It still wrapped to two rows, and that second row is 44px of the 390 — the title
block and the five buttons came to 829px inside a 765px header at 844x390, so
they missed sharing one line by 64px, and at 640x360 by rather more. What goes is
the page title: the one thing on this screen a dealer already knows. It is hidden
from the eye rather than removed, so the page keeps its heading for a screen
reader and for the browser's own outline. The hand number stays — nothing else on
the screen says which hand this is. */
#holdemHeader {
margin-bottom: 0.4rem;
gap: 0.35rem;
flex-wrap: nowrap;
align-items: center;
}
#holdemHeader h1 {
font-size: 1.05rem;
padding-bottom: 0.15rem;
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.handCounterBadge {
margin-top: 0;
}
/* Tightened across, never down: the buttons keep the 44px a thumb needs. */
#holdemHeaderActions {
flex-wrap: nowrap;
gap: 0.35rem;
}
#holdemHeaderActions .holdemBtn {
padding-left: 0.55rem;
padding-right: 0.55rem;
font-size: 0.78rem;
white-space: nowrap;
}
.holdemSubtitle {
@@ -3200,12 +3569,30 @@ body.hasActionBar #toastHost {
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
}
/* The clock's ruling, said in a sentence, is a line this screen cannot pay for:
measured at 640x360, the bar goes from 152px to 269px when the count runs out and
the bottom seats end up behind it. The button carries the same ruling in three
words — "Kill Dee's hand" — so the sentence is what goes, not the call. */
.clockExpired {
display: none;
}
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
.seat {
width: 62px;
min-height: 40px;
}
/* The same inset the phone layout uses, which this branch never had because it is
matched on height rather than width — so an 844px-wide phone on its side drew its
ring against the felt's full box. With the rim a stadium, the top rail is flat,
which put the button seat's dealer chip over the Resolve Pot button in the header
above. Percentages of a short felt, so the ring keeps the height it needs. */
#seatsLayer,
#betsLayer {
inset: min(26px, 12%) 44px;
}
}
/* ---------- Short phones, upright ----------
@@ -3387,6 +3774,38 @@ textarea:focus-visible {
font-weight: 600;
}
/* The straddle rides on the deal row, so it must not take half of it: .actionBtn is
flex: 1 and would have split the row evenly with the button the dealer presses every
hand. It sizes to its own words instead, and stays quiet next to the green one —
choosing a straddle is the exception, dealing is the rule. */
.actionBtn.actionStraddle {
flex: 0 1 auto;
background: linear-gradient(180deg, #33304a, #24223a);
border-color: #4b4770;
color: #e6e1ff;
font-size: 0.82rem;
padding-left: 0.7rem;
padding-right: 0.7rem;
}
.actionBtn.actionStraddleOff {
background: linear-gradient(180deg, #2e2e2e, #232323);
border-color: #4a4a4a;
color: #dcdcdc;
}
/* Three buttons across a 360px phone is a squeeze, and the two ways out are both
worse than this one. Letting the row wrap put the deal button on its own line and
made the bar 64px taller — measured at 360x640, that is the bottom seat three fifths
behind the bar, and this layout deliberately doesn't shrink the felt to suit (see the
max-width: none override, where capping it was tried and rejected). Letting flex
squash freely took the deal button to 84px with its label over three lines.
A floor of 7rem keeps the label to two lines and the row to one, so arming a straddle
costs no felt at all. */
.dealPromptButtons .actionDeal {
min-width: 7rem;
}
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
would otherwise only discover afterwards. */
.dealPromptLine.dealPromptAside {
@@ -3394,6 +3813,38 @@ textarea:focus-visible {
color: #9a9a9a;
}
/* An aside that has something to press. It wraps rather than squeezing the button:
on a phone the sentence takes the width it needs and the button drops under it,
still at full tap size, instead of being crushed to a sliver at the end of a line. */
.dealPromptLine.dealPromptDebt {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.4rem 0.55rem;
}
.dealPromptDebtBtn {
/* Sized down from a full action button — it's an option beside a sentence, not
the thing the dealer came here to press. "Post blinds & deal" stays the button
that looks like the button. */
flex: 0 0 auto;
min-height: 32px;
padding: 0.24rem 0.7rem;
font-size: 0.72rem;
background: rgba(245, 213, 71, 0.1);
border: 1px solid rgba(245, 213, 71, 0.45);
color: #f0d98c;
}
@media (max-width: 700px), (pointer: coarse) {
/* A fingertip needs 44px, and this one moves money. */
.dealPromptDebtBtn {
min-height: 44px;
padding: 0.4rem 0.9rem;
}
}
/* ---------- Touch targets on a wide touch screen ----------
Every touch-target rule in this file sits behind `max-width: 700px`, and width was
@@ -3417,7 +3868,9 @@ textarea:focus-visible {
.panelBody .holdemBtnSmall,
.handHistoryActions .holdemBtn,
.toastAction {
.toastAction,
.cardPickerHeader .holdemBtn,
.actionUndo {
min-height: 44px;
}
@@ -143,12 +143,18 @@ button,
}
.unevenCurrency {
color: #777;
/* Was #777 — 4.11:1 on this background, under the 4.5:1 small text needs. It sits
against every amount a dealer types, so it is read constantly. */
color: #8f8f8f;
}
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
44px everywhere else since the round that set it; this page never got measured for
it, because the audit only ran the target check when asked and nothing asked. */
.unevenAmount {
flex: 1;
min-width: 0;
min-height: 46px;
background: none;
border: none;
outline: none;
@@ -178,13 +184,17 @@ button,
color: #ffd6d6;
}
/* 30px across, and it deletes a player's entry — the one control on the row where a
near miss costs something. The amount field beside it gives up the 16px. */
.unevenRemoveBtn {
flex: none;
width: 30px;
height: 44px;
width: 46px;
height: 46px;
border: none;
background: none;
color: #6a6a6a;
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
player's entry. A destructive action should not be the faintest thing in view. */
color: #9a9a9a;
font-size: 0.85rem;
cursor: pointer;
}
@@ -209,11 +219,86 @@ button,
.unevenPot {
display: flex;
align-items: baseline;
/* Wraps so a chop opens underneath its own pot rather than squeezing in beside the
eligible-players list and pushing it off the row. */
flex-wrap: wrap;
gap: 0.6rem;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.unevenChopToggle {
flex: none;
order: 3;
/* 44px on touch: it moves money, like everything else that does. */
min-height: 32px;
padding: 0.2rem 0.6rem;
border: 1px solid rgba(216, 180, 106, 0.45);
border-radius: 999px;
background: rgba(216, 180, 106, 0.1);
color: #e3c887;
font: inherit;
font-size: 0.74rem;
cursor: pointer;
}
.unevenChopToggle:hover {
background: rgba(216, 180, 106, 0.18);
}
.unevenChopToggle:focus-visible {
outline: 2px solid #d8b46a;
outline-offset: 2px;
}
.unevenChopDetail {
order: 4;
flex: 0 0 100%;
/* Or the padding is added on top of the 100% basis and the panel sits wider than
the row it belongs to — measured 328px inside a 309px row on a phone. */
box-sizing: border-box;
margin: 0.15rem 0 0.2rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
}
.unevenChopWays {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.3rem;
}
.unevenChopWaysLabel {
color: #cfcfcf;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.unevenChopEach {
margin: 0;
color: #f2f2f2;
font-weight: 600;
}
.unevenChopNote {
margin: 0.2rem 0 0;
color: #9a9a9a;
font-size: 0.76rem;
}
.unevenChopDust {
color: #e0b169;
}
@media (max-width: 700px), (pointer: coarse) {
.unevenChopToggle {
min-height: 44px;
padding: 0.4rem 0.9rem;
}
}
.unevenPot + .unevenPot {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
@@ -510,7 +595,9 @@ button,
.splitFootnote {
margin-top: 1.5rem;
color: #6f6f6f;
/* Was #6f6f6f — 3.94:1. It carries the instruction for what to do with the pot
list, which is not a decorative line. */
color: #8d8d8d;
font-size: 0.78rem;
line-height: 1.5;
text-align: center;
@@ -53,7 +53,14 @@ const ActionBar = {
// moving the seats, and getting a different answer on the next pass.
SEAT_OVERHANG_PX: 37,
FELT_GAP_PX: 8,
FELT_MIN_PX: 220,
// The floor is a guard against a measurement that has gone wrong — not a size the
// window has agreed to hand over. It used to be 220px, which a phone on its side
// hasn't got: at 844x390 there are 99px between the header and the bar, and holding
// out for 220 is how the bottom row of seats came to sit 26px inside the Fold
// button. A floor that puts seats behind the bar defeats the one thing the floor is
// for, so it drops to the smallest felt still worth drawing and the measurement
// wins above it.
FELT_FLOOR_PX: 96,
// How tall the felt can be and still leave the whole ring of seats above the bar.
//
@@ -66,21 +73,39 @@ const ActionBar = {
//
// The wrap's offset is taken in document space rather than viewport space, so
// scrolling the page doesn't resize the table under the dealer's hands.
feltRoomFor(windowHeight, barHeight, topInDocument) {
const room = windowHeight - barHeight - topInDocument -
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
return Math.max(this.FELT_FLOOR_PX, Math.round(room));
},
publishFeltRoom(barHeight) {
const wrap = document.getElementById("pokerTableWrap");
if (!wrap) return;
// Taking the top before the height is set matters: the wrap sits below the
// header in normal flow, so its own height never moves its top and this can't
// chase itself around the screen.
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
const room = window.innerHeight - barHeight - topInDocument -
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
document.documentElement.style.setProperty(
"--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
"--feltRoomPx",
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
// The felt has just changed size, so how much rim each seat gets has changed
// with it. Told rather than left to be noticed on the next render, which on a
// bar that grew mid-hand could be several actions away.
if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
// still holds during setup, because a seated player is a live seat whether or not
// the betting has been opened. An empty table stays uncluttered.
//
// A *named* table is not an empty one, though, and that was the hole: name six
// people without dealing any of them in and the bar hid itself, leaving a screen
// with six players on it, no way forward and nothing saying why. Anyone with a
// name on the felt is somebody the dealer has to be told about.
isRunning() {
return Betting.liveSeats(this.state).length > 0;
return Betting.liveSeats(this.state).length > 0 ||
this.state.seats.some(p => (p.name || "").trim());
},
render() {
@@ -169,6 +194,32 @@ const ActionBar = {
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
row.appendChild(tank);
// Rendered every time and shown by the tick once the wait is long enough
// to be worth it, so a clock never costs a re-render at the moment the
// dealer reaches for it — and never occupies the bar in the ordinary case
// where nobody is holding anybody up.
const clock = document.createElement("button");
clock.type = "button";
clock.id = "actionClockBtn";
clock.className = "actionClockBtn";
// Once the count is over there is nothing to stop: the ruling has been
// made, and the dealer either carries it out or takes an ordinary action,
// both of which end the clock themselves. Leaving the button there also
// cost real room — at 640x360 its 94px pushed the undo control onto a
// second line, and the taller bar covered the two bottom seats.
const expiredNow = this.clockRunning() && this.clockLeft() === 0;
clock.hidden = !this.clockRunning() || expiredNow;
if (this.clockRunning()) {
clock.textContent = "Stop the clock";
clock.title = "Put the decision back in the player's own time";
clock.addEventListener("click", () => { this.stopClock(); this.render(); });
} else {
clock.textContent = "Call the clock";
clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`;
clock.addEventListener("click", () => this.callClock());
}
row.appendChild(clock);
}
// A standing control rather than a toast per action: a dealer taps this bar
@@ -208,6 +259,74 @@ const ActionBar = {
NUDGE_AFTER_S: 30,
SLOW_AFTER_S: 60,
// ---- Calling the clock ----
//
// The stopwatch above says how long somebody has been thinking. This is what a
// dealer does about it, and it is a procedure with a shape: a player asks for a
// clock, the dealer grants it, the player gets a set time and then a countdown,
// and if they still haven't acted the hand is decided for them — dead if they were
// facing a bet, checked if they weren't.
//
// The tool knew none of that. A dealer running it had to count in their head while
// the table argued about whether it had been thirty seconds, which is the moment
// the count needs to be somewhere everybody can see.
//
// Thirty seconds and then a ten-second countdown is the common form of the rule and
// what the TDA writes down. The countdown is the part the table hears out loud, so
// it is marked separately rather than being ten more seconds of the same.
CLOCK_THINK_S: 30,
CLOCK_COUNTDOWN_S: 10,
clockSeatIndex: null,
clockStartedAt: null,
clockRunning() {
return this.clockSeatIndex !== null &&
this.clockSeatIndex === this.state.actingSeatIndex &&
this.clockStartedAt !== null;
},
// Seconds left before the hand is decided, or null when no clock is running.
// Negative is impossible: it stops at zero and stays there until the dealer acts.
clockLeft(now = Date.now()) {
if (!this.clockRunning()) return null;
const total = this.CLOCK_THINK_S + this.CLOCK_COUNTDOWN_S;
return Math.max(0, total - Math.floor((now - this.clockStartedAt) / 1000));
},
// What happens when it reaches zero, in the words the dealer says out loud.
// Facing a bet, the hand is dead. Facing none, it is checked — a player who could
// have checked for free is not folded for being slow.
clockRuling() {
const index = this.state.actingSeatIndex;
if (index === null || index === undefined) return null;
const player = this.state.seats[index];
const owed = Betting.toCall(this.state, player);
const who = HoldemState.displayName(player, index);
return owed > 0
? { index, who, verb: "fold", label: `Time — ${who}'s hand is dead`, say: `${who} is facing ${Money.format(owed)}, so the hand is dead.` }
: { index, who, verb: "check", label: `Time — check for ${who}`, say: `${who} had nothing to call, so the hand is checked.` };
},
callClock() {
const index = this.state.actingSeatIndex;
if (index === null || index === undefined) return;
this.clockSeatIndex = index;
this.clockStartedAt = Date.now();
this.clockExpiredShown = false;
this.render();
},
stopClock() {
this.clockSeatIndex = null;
this.clockStartedAt = null;
this.clockExpiredShown = false;
},
// Whether the bar has already been rebuilt into its "time is up" shape, so the
// one-second tick doesn't rebuild it again every second afterwards.
clockExpiredShown: false,
tankSeatIndex: null,
tankStartedAt: null,
tankInterval: null,
@@ -237,6 +356,11 @@ const ActionBar = {
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
// A clock belongs to one player's decision. The moment the action moves —
// because they acted, or because the dealer took something back — it is
// over, and leaving it running would put the next player on a countdown
// nobody called on them.
this.stopClock();
}
if (!this.tankInterval) {
@@ -258,6 +382,36 @@ const ActionBar = {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
// A clock takes the readout over: the number the table is waiting on is no
// longer how long this has taken but how long is left. At zero the bar has to
// change shape — a ruling and the button that carries it out — so that one
// gets a full render, once, rather than being painted in by the tick.
const left = this.clockLeft();
if (left !== null) {
if (left === 0 && !this.clockExpiredShown) {
this.clockExpiredShown = true;
this.render();
return;
}
el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
el.title = "Time left before the hand is decided";
el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
return;
}
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking — which is the same threshold the
// stopwatch already marks. Before that it is a control nobody needs, and this
// bar is docked over the seats.
const nudge = document.getElementById("actionClockBtn");
if (nudge && !this.clockRunning()) {
const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
? false
: waited < this.NUDGE_AFTER_S;
}
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—";
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
@@ -380,6 +534,36 @@ const ActionBar = {
const stack = Betting.stackOf(this.state, player);
const who = HoldemState.displayName(player, index);
// Time is up. The ruling goes on the bar in the words the dealer says out loud,
// with the one action that carries it out — and the ordinary buttons stay
// underneath, because a player who speaks up as the count ends is still acting
// and the dealer is the one who decides whether it was in time.
const expired = this.clockLeft() === 0 ? this.clockRuling() : null;
const wrap = document.createElement("div");
if (expired) {
const line = document.createElement("p");
line.className = "clockExpired";
// The ruling in one sentence. `label` is the same call in three words for
// the button beside it; saying both left the line doubling back on itself
// — "Time — Dee's hand is dead — Dee is facing $2, so the hand is dead."
line.textContent = `Time — ${expired.say}`;
wrap.appendChild(line);
const enforce = document.createElement("div");
enforce.className = "actionButtons";
enforce.appendChild(this.button(
expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
"actionEnforceClock",
() => this.commit(
expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`,
() => {
this.stopClock();
if (expired.verb === "fold") Betting.fold(this.state, index);
else Betting.check(this.state, index);
})));
wrap.appendChild(enforce);
}
const row = document.createElement("div");
row.className = "actionButtons";
@@ -406,7 +590,9 @@ const ActionBar = {
}));
}
return row;
if (!expired) return row;
wrap.appendChild(row);
return wrap;
},
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
@@ -424,7 +610,6 @@ const ActionBar = {
const player = this.state.seats[index];
const stack = Betting.stackOf(this.state, player);
const maxTo = Betting.round(Betting.committed(player) + stack);
const pot = Betting.potSoFar(this.state);
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
// what this player can actually put in, which is what the Min button should
// offer. `legalMin` is what the rules require — for a short stack the two are
@@ -440,10 +625,16 @@ const ActionBar = {
const quick = document.createElement("div");
quick.className = "raiseQuick";
// The pot amounts come from Betting, which knows that a pot raise is measured
// off the pot as it stands *after* the caller's own call goes in. The pad used
// to work it out here as "the bet plus the pot", which left the call out and
// made both amounts short: pre-flop at 1/2 it offered "Pot $5" where a pot
// raise is $7, and a half-pot that clamped up to the minimum and sat next to
// the Min button showing the same $4 twice.
const options = [
{ label: "Min", value: minTo },
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
{ label: "½ pot", value: Betting.potRaiseTo(this.state, index, 0.5) },
{ label: "Pot", value: Betting.potRaiseTo(this.state, index, 1) },
{ label: "All-in", value: maxTo },
];
options.forEach(option => {
@@ -566,6 +757,43 @@ const ActionBar = {
// by itself the instant the previous hand ended, which meant blinds were committed
// while people were still sitting down — and the blind badges would drift away from
// the money already posted as seats changed. Now the dealer says when.
// ", Dee straddles $4" — read out as part of what goes in before the cards, because
// that is what it is. Two of them read as "Dee straddles $4, Eve $8": the second
// name doesn't need the verb again, and the bar has no room for it.
straddleClause(preview) {
const straddles = preview.straddles || [];
if (straddles.length === 0) return "";
const name = i => HoldemState.displayName(this.state.seats[i], i);
const [first, ...rest] = straddles;
return `, ${name(first.index)} straddles ${Money.format(first.amount)}` +
rest.map(s => `, ${name(s.index)} ${Money.format(s.amount)}`).join("");
},
// Turning it on, doubling it, and calling it off. Heads-up there is no seat left of
// the big blind to straddle from, so nothing is offered rather than a button that
// would do nothing.
straddleButtons(preview) {
const on = (preview.straddles || []).length;
const max = preview.maxStraddles || 0;
if (max <= 0) return [];
const buttons = [];
const nextAmount = Money.format(this.state.bigBlind * Math.pow(2, on + 1));
if (on < max) {
buttons.push(this.button(
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
"actionStraddle",
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
() => { this.state.straddleCount = on + 1; })));
}
if (on > 0) {
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
() => this.commit("calling the straddle off",
() => { this.state.straddleCount = 0; })));
}
return buttons;
},
renderDealPrompt() {
const wrap = document.createElement("div");
wrap.className = "dealPrompt";
@@ -587,10 +815,19 @@ const ActionBar = {
// thing: "only one player is dealt in" is wrong when four are sitting there
// and three of them are just out of chips.
const withChips = seated - broke.length;
// "Nobody has chips left" is the wrong story for a table that never had
// any. A dealer who has just named six people and not yet said what a
// buy-in is gets told their players are busted, which sends them looking
// for a rebuy button instead of the one field that fixes it.
const nobodyHasEverBoughtIn = this.state.seats.every(p =>
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
if (broke.length > 0 && withChips < 2) {
line.textContent = withChips === 1
? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
: "Nobody at the table has chips left. Add a buy-in to deal.";
: nobodyHasEverBoughtIn
? "Nobody has bought in yet. Set what everyone starts with, or give each seat their chips."
: "Nobody at the table has chips left. Add a buy-in to deal.";
} else {
line.textContent = seated === 0
? "Nobody is dealt in yet. Tap a seat to add a player."
@@ -598,15 +835,83 @@ const ActionBar = {
}
line.classList.add("dealPromptBlocked");
wrap.appendChild(line);
// A blocked prompt with nothing to press is a dead end, and this was the
// one a first-time dealer hits: six players named, a sentence saying they
// have no chips, and no button anywhere on the bar. The starting stack is a
// single field in the sidebar and the message never pointed at it.
if (nobodyHasEverBoughtIn && seated > 0) {
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Set starting stack", "actionDeal", () => {
const field = document.getElementById("defaultStackInput");
if (!field) return;
const panel = field.closest("details");
if (panel) panel.open = true;
field.scrollIntoView({ block: "center", behavior: "smooth" });
field.focus();
field.select();
}));
wrap.appendChild(row);
return wrap;
}
// Named seats that aren't in the hand. Naming a chair now seats whoever is
// in it, so reaching this takes something else — a table where everyone was
// sat out, or a save from before that was true — but the way out of it
// should still be one tap rather than a hunt through six seat editors.
const benched = this.state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
.map(({ index }) => index);
if (benched.length > 0) {
const aside = document.createElement("p");
aside.className = "dealPromptLine dealPromptAside dealPromptDebt";
const text = document.createElement("span");
text.textContent = benched.length === 1
? `${listNames(benched)} is sitting out.`
: `${listNames(benched)} are sitting out.`;
aside.appendChild(text);
aside.appendChild(this.button(
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
"dealPromptDebtBtn",
() => this.commit("dealing everybody in", () => {
benched.forEach(index => {
this.state.seats[index].inHand = true;
this.state.seats[index].folded = false;
});
})
));
wrap.appendChild(aside);
}
return wrap;
}
line.textContent =
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
// the small blind" out loud is the whole point — a dealer who reads a preview
// with a blind missing and isn't told why will assume the tool has lost track.
const blinds = document.createElement("span");
blinds.textContent = (preview.deadSmallBlind
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`) +
this.straddleClause(preview) +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
line.appendChild(blinds);
wrap.appendChild(line);
// Why a position is missing. Both of these are the dead button rule doing its
// job, and both look like a bug until somebody says otherwise.
if (preview.deadButton || preview.deadSmallBlind) {
const dead = document.createElement("p");
dead.className = "dealPromptLine dealPromptAside";
dead.textContent = preview.deadButton
? `Dead button — seat ${preview.buttonIndex + 1} is empty. Its player left out of the blinds, so the button waits a hand rather than moving the big blind on twice.`
: `Dead small blind — the player due to post it left the table. The big blind still moves on one player, so nobody pays two in an orbit.`;
wrap.appendChild(dead);
}
// Dealing relocates a button sitting on an empty chair. That has to be said
// before it happens rather than discovered afterwards — a fresh table starts
// the button on seat 1 whether or not anyone is sitting there.
@@ -628,8 +933,51 @@ const ActionBar = {
wrap.appendChild(out);
}
// Somebody who has just sat down between the button and the big blind has had
// the blinds go past them, and dealing them in there is a free hand in late
// position. At a table the answer is that they post the big blind to come in
// now, or wait for it to reach them. The tool used to do neither and say
// nothing: it dealt them in for free, and the dealer had no way to know it had
// happened. Both answers are offered here; neither is forced, because a home
// game that doesn't care shouldn't have to argue with its own scoreboard.
// One line for however many have walked up, not one each. The bar is docked
// over the felt and the felt has already shrunk as far as it will go on a
// phone, so every line here is taken out of the ring of seats: measured at
// 390x844 with two newcomers, a line apiece put the bar over two fifths of the
// bottom seat. They post together or they wait together, which is what happens
// at a table anyway when two people sit down at once.
if (preview.debtors.length > 0) {
const ids = preview.debtors.map(i => this.state.seats[i].id);
const posting = ids.every(id => preview.posting.includes(id));
const who = listNames(preview.debtors);
const each = preview.debtors.length > 1 ? " each" : "";
const line = document.createElement("p");
line.className = "dealPromptLine dealPromptAside dealPromptDebt";
const text = document.createElement("span");
text.textContent = posting
? `${who} posting ${Money.format(preview.bigBlind)}${each} to come in.`
: `${who} sat down after the blinds — ${preview.debtors.length > 1 ? "free hands" : "a free hand"} unless they post.`;
line.appendChild(text);
line.appendChild(this.button(
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
"dealPromptDebtBtn",
() => this.commit(
posting ? `${who} waiting for the blind` : `${who} posting to come in`,
() => { this.state.postingIn = posting ? [] : ids; })
));
wrap.appendChild(line);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.className = "actionButtons dealPromptButtons";
// Beside the deal button rather than on a line of its own. Measured at 390x844:
// its own line cost 44px of felt on every deal prompt, straddle or no straddle,
// and this row is already there and already 44px tall. It reads in the order it
// happens, too — say the straddle, then deal.
this.straddleButtons(preview).forEach(button => row.appendChild(button));
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
@@ -638,13 +986,41 @@ const ActionBar = {
return wrap;
},
// "Dee shows first — last to raise." The call the dealer has to make at every
// showdown and that nobody at the table volunteers for, said out loud with the
// reason attached so it can be repeated to whoever argues with it.
showdownCall() {
const call = Betting.showdownOrder(this.state);
if (!call) return null;
const who = HoldemState.displayName(this.state.seats[call.index], call.index);
const because = {
aggressor: "last to bet or raise",
// Semicolon, not a second dash: the sentence already spends its dash on
// "shows first —", and two of them in one line reads as a stutter.
allInAggressor: "last to put money in; nobody could bet the river",
leftOfButton: "checked round, first left of the button",
}[call.reason];
const line = document.createElement("p");
line.className = "showdownCall";
line.textContent = `${who} shows first — ${because}.`;
return line;
},
renderStreetDone() {
const row = document.createElement("div");
row.className = "actionButtons";
const wrap = document.createElement("div");
const isRiver = this.state.street === "river";
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
if (isRiver) {
const call = this.showdownCall();
if (call) wrap.appendChild(call);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button(label, "actionAdvance", () => {
if (isRiver) {
this.hooks.resolvePot();
@@ -652,22 +1028,33 @@ const ActionBar = {
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
}));
wrap.appendChild(row);
return row;
return wrap;
},
renderHandOver() {
const row = document.createElement("div");
row.className = "actionButtons";
const wrap = document.createElement("div");
const live = Betting.liveSeats(this.state);
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
// Two or more still in with the betting finished is a showdown however it was
// arrived at — including everyone all-in on the flop, where the cards go on
// their backs and the order still decides who turns over first.
if (!winner) {
const call = this.showdownCall();
if (call) wrap.appendChild(call);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button(
winner ? `Award pot to ${winner}` : "Resolve pot",
"actionAdvance",
() => this.hooks.resolvePot()
));
return row;
wrap.appendChild(row);
return wrap;
},
};
@@ -78,18 +78,75 @@ const Betting = {
: { ...dealt, dealerSeatIndex: buttonIndex };
const satOut = this.brokeSeats(state);
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
if (sbIndex === null || bbIndex === null) return null;
const { sbIndex, bbIndex, headsUp, manual, deadSmallBlind } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return null;
return {
sbIndex, bbIndex, headsUp, manual: !!manual,
sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind,
deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand,
// Asked of the table as it will be dealt, for the same reason the blinds
// are: someone sitting out for having no chips isn't owed a blind, and the
// big blind of the hand about to go out isn't either.
debtors: HoldemState.blindDebtors(asDealt, bbIndex),
posting: (state.postingIn || []).slice(),
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
// What the straddles will be if the hand is dealt as it stands, and how
// many more this table could take.
straddles: this.straddleSeats(state),
maxStraddles: this.maxStraddles(state),
};
},
// ---- Straddles ----
//
// A straddle is a blind raise put out before the cards, by the player left of the
// big blind, for twice it. It buys the last word before the flop: everyone else
// acts first, and the straddler still has the option when it comes back round even
// if nobody raised. Re-straddles carry on left, each doubling again.
//
// Home games run them constantly and a dealer has to get three things right at
// once — the amount, who is first to act, and what the minimum raise becomes. The
// tool used to do none of them, so a dealer's only way through was to deal, have
// the straddler "raise" to $4, and hand the action to the wrong player.
// How many straddles this table could take. Everyone but the two blinds can put
// one out; heads-up that is nobody, which is the right answer — the button is
// already the small blind and there is no seat left of the big blind to straddle
// from.
maxStraddles(state) {
return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
},
// Who straddles and for how much, walking left from the big blind and doubling.
// Asked of the table as it will be dealt, so a seat about to be sat out for having
// no chips is never handed a straddle it can't post.
straddleSeats(state, count = state.straddleCount) {
const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
if (wanted <= 0) return [];
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
const { bbIndex } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return [];
const straddles = [];
let index = bbIndex;
let amount = this.round((Number(state.bigBlind) || 0) * 2);
for (let n = 0; n < wanted; n++) {
index = this.nextToAct(asDealt, index);
if (index === null || index === bbIndex) break;
straddles.push({ index, amount });
amount = this.round(amount * 2);
}
return straddles;
},
// Does handing this seat chips also put them back in the game?
//
// At a real table those are one event, and here they were two: a busted player got
@@ -270,6 +327,11 @@ const Betting = {
if (newBet > previousBet) {
const increase = this.round(newBet - previousBet);
// Aggression, whether or not it was a full raise — a short all-in doesn't
// reopen the betting, but the player who made it is still the last one who
// put money in, and at the showdown they are still the one who shows first.
state.streetAggressorIndex = index;
state.handAggressorIndex = index;
// What a full raise would have had to be: at least as much again as the
// last one, or the big blind if there hasn't been a raise yet.
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
@@ -331,6 +393,10 @@ const Betting = {
state.currentBet = 0;
state.lastRaiseSize = 0;
// Whoever led this street stops being the answer the moment the next one opens.
// The hand's aggressor is deliberately left alone: it's the fallback for a
// river nobody could bet on.
state.streetAggressorIndex = null;
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
state.street = this.STREETS[nextStreetIndex] || "river";
@@ -393,6 +459,55 @@ const Betting = {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
// 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
@@ -418,6 +533,11 @@ const Betting = {
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
// Posting a blind isn't aggression — a hand limped and checked all the way
// down has no aggressor at all, which is exactly the case the showdown rule
// sends to the seat left of the button.
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
// Deal around anyone with no chips, before anything else reads the table.
//
@@ -448,24 +568,148 @@ const Betting = {
const bb = Number(state.bigBlind) || 0;
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
if (sbIndex === null || bbIndex === null) {
// A null big blind means there aren't enough players to post at all. A null
// small blind with a big blind behind it is the dead small blind, which is a
// hand that gets dealt like any other — it just has one blind in it.
if (bbIndex === null) {
state.currentBet = 0;
state.actingSeatIndex = null;
state.postedBlinds = null;
state.postedStraddles = [];
return;
}
if (sb > 0) this.putIn(state, sbIndex, sb);
if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
if (bb > 0) this.putIn(state, bbIndex, bb);
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
// Anyone the dealer said would post to come in past the blinds. It's a big
// blind like any other — it buys them a hand they'd otherwise have to wait for
// — so it goes in the middle the same way, before the count below reads it.
const posting = Array.isArray(state.postingIn) ? state.postingIn : [];
if (bb > 0) {
state.seats.forEach((player, index) => {
if (index === sbIndex || index === bbIndex) return;
if (player.inHand && posting.includes(player.id)) this.putIn(state, index, bb);
});
}
// Deliberately not cleared here. repostBlinds takes every posted chip back and
// deals again through this same function, so a post that cleared itself on the
// way in would be refunded and never retaken — the dealer moves the button and
// somebody's post quietly vanishes. startNextHand is what ends it.
// What the next hand's rotation is worked out from. Recorded here, where the
// blinds are actually posted, because by the time it's needed the seats have
// moved on and the answer can no longer be read off the table.
state.postedBlinds = { sbIndex, bbIndex };
// Who got cards. Read next hand to tell a player who has been here all along
// from one who has just walked up and had the blinds go past them.
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
// Straddles go out after the blinds and before the cards, each one left of the
// last. Posted here rather than left to the dealer to fake as a raise, because
// a raise from that seat would hand the action to the wrong player.
const straddles = this.straddleSeats(state);
straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
state.postedStraddles = straddles.map(({ index }) => index);
state.currentBet = this.round(state.seats.reduce(
(most, player) => Math.max(most, this.committed(player)), 0));
// A straddle is a blind raise, so what has to be matched to raise again is the
// straddle itself, not the big blind underneath it: $2 blinds with a $4
// straddle make the next raise $8, the same way the $2 blind makes it $4.
// Left alone, the minimum would come out at $6 and the dealer would be talked
// out of it by every player at the table.
state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
// Heads-up the button is the small blind and acts first before the flop; with
// three or more it's the seat after the big blind. Either way, a blind big
// enough to put that player all-in leaves them nothing to decide.
//
// A straddle moves that seat along: first to act is left of the last straddler,
// and the straddler keeps the option, which falls out of nobody having acted
// yet.
const lastPosted = straddles.length > 0
? straddles[straddles.length - 1].index
: bbIndex;
state.actingSeatIndex = headsUp
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
: this.nextToAct(state, bbIndex);
: this.nextToAct(state, lastPosted);
},
// What a pot-sized raise actually is, as a "raise to" total.
//
// Not "the bet plus the pot as it stands". The raiser calls first, and their call
// is in the pot before the raise is measured off it, so the pot being matched is
// the one that exists after the call:
//
// raise to = the current bet + (pot + what they owe) × fraction
//
// Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then
// raise $5. Leaving the call out understates every one of these, and players say
// "pot" out loud meaning a definite number.
//
// Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is
// zero, so it comes out as the pot, which is the right answer there too.
// Rounded up to something the table's chips can actually build. Half the pot is
// constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest
// chip in the default legend is a dollar, so the button offered a number the
// dealer would have to round themselves every single time.
//
// Only when the legend describes this game. A smallest chip *larger* than the big
// blind means the denominations were never set for these stakes, and rounding a
// 5c/10c table up to whole dollars would be the tool doing real damage on the
// strength of a field somebody forgot to fill in. Left exact there, and the wrong
// legend stays visible rather than being quietly applied.
potRaiseTo(state, index, fraction = 1) {
const player = state.seats[index];
if (!player) return 0;
const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player));
const exact = this.round((state.currentBet || 0) + potAfterCall * fraction);
const step = Money.fromCents(Money.chipStepCents(state.chipLegend));
const bigBlind = Number(state.bigBlind) || 0;
if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact;
return Money.upToChip(exact, state.chipLegend);
},
// Who turns their hand over first.
//
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
// the table volunteers for — everyone would rather see a hand than show one. The
// tool knew who last raised and who sits left of the button and said neither, so
// the dealer was working it out from memory while five people waited.
//
// Three cases, in the order the rule asks them:
// * somebody bet or raised on the final round — they show first;
// * the round was checked through — first live player left of the button;
// * nobody could bet, because the chips were already in — the last player who
// did put money in, on whichever round that was.
//
// Returns null when there's nothing to call: one player left, or none.
showdownOrder(state) {
const live = this.liveSeats(state);
if (live.length < 2) return null;
const seatOf = index =>
Number.isInteger(index) && live.some(s => s.index === index) ? index : null;
const streetAggressor = seatOf(state.streetAggressorIndex);
if (streetAggressor !== null) {
return { index: streetAggressor, reason: "aggressor" };
}
// Nobody could have bet this round, so "no bet on the final round" isn't a
// choice anybody made and the seat left of the button hasn't earned the
// obligation. It falls to whoever last put money in.
if (!this.canStillBet(state)) {
const handAggressor = seatOf(state.handAggressorIndex);
if (handAggressor !== null) return { index: handAggressor, reason: "allInAggressor" };
}
const order = HoldemState.liveOrderFromButton(state)
.filter(index => live.some(s => s.index === index));
return order.length > 0 ? { index: order[0], reason: "leftOfButton" } : null;
},
// Winners get their share put back in front of them.
@@ -20,7 +20,11 @@ const HandHistoryExport = {
// who owes what — doesn't work without cleaning every cell first. The commas were
// quoted correctly, so the file parsed; it just wouldn't add up.
toCsv(handHistory, standings = [], { notes = "" } = {}) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// The rake gets its own column rather than being folded into the pot: the two
// numbers answer different questions — what was in the middle, and what the
// house took out of it — and a night's worth of the second one is what the box
// is counted against.
const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
@@ -30,6 +34,7 @@ const HandHistoryExport = {
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.forSheet(entry.pot),
Money.forSheet(entry.rake || 0),
payouts,
]);
});
@@ -49,7 +54,10 @@ const HandHistoryExport = {
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
standings.forEach(row => {
rows.push([
row.name,
// Somebody who has gone home is marked in the file the same way the
// panel marks them, because this is the copy that gets read at the
// end of the night when nobody remembers who has already been paid.
row.cashedOut ? `${row.name} (paid out)` : row.name,
Money.forSheet(row.in),
Money.forSheet(row.won),
Money.forSheet(row.net),
+243 -9
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 = (seat.name || "").trim() || Number(seat.buyIns) > 0 || Number(seat.stack) > 0;
if (occupied) {
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
hooks.rerender();
return;
}
runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
() => HoldemState.seatFromWaitingList(state, id, index));
},
dropWaiting: id => {
const entry = (state.waitingList || []).find(row => row.id === id);
if (!entry) return;
runUndoable(`${entry.name} taken off the waiting list.`,
() => HoldemState.removeFromWaitingList(state, id));
},
onGiveButton: index => seatMenuHooks.makeDealer(index),
};
@@ -96,6 +125,39 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
// Somebody is done for the night. The dealer counts their chips out, the chair
// opens, and both halves of what happened stay on the record — the cash the box
// took from them, and the chips it handed back. Wiping the seat by hand did
// neither: it left the bank check declaring their whole stack missing for the
// rest of the night.
cashOut: async index => {
const allowed = HoldemState.canCashOut(state, index);
if (!allowed.ok) {
Toast.show(allowed.reason, { duration: 4000 });
return;
}
const preview = HoldemState.cashOutPreview(state, index);
const result = preview.net === 0
? "even for the night"
: preview.net > 0
? `up ${Money.format(preview.net)}`
: `down ${Money.format(Math.abs(preview.net))}`;
const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
const ok = await Dialog.confirm({
title: `Cash out ${preview.name}?`,
message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
`${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
`night's record keeps both their buy-in and this payout so the bank still balances.`,
confirmLabel: `Pay ${Money.format(preview.chips)}`,
});
if (!ok) return;
runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
() => HoldemState.cashOut(state, index));
},
// Two players change chairs. Undoable like every other seat action, because a
// swap is easy to aim wrong and the seats look alike at a glance.
swapSeats: (from, to) => {
@@ -121,6 +183,11 @@ document.addEventListener("DOMContentLoaded", () => {
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
: `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
// Putting the button somewhere by hand is the dealer overruling the
// rotation, dead positions included. Leaving them set would hold a
// small blind dead at a table whose button has moved out from under it.
state.deadButton = false;
state.deadSmallBlind = false;
if (willRepost) Betting.repostBlinds(state);
});
},
@@ -187,11 +254,15 @@ document.addEventListener("DOMContentLoaded", () => {
hooks.save(state);
Render.renderAll();
PotWizard.open(state, payouts => {
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
const withRake = rake > 0 ? `${paid}, house ${Money.format(rake)}` : paid;
runUndoable(withRake ? `Paid ${withRake}.` : "Hand closed with nothing to pay out.", () => {
if (rake > 0) {
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
}
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts);
HoldemState.recordHand(state, payouts, rake);
// Winnings go straight back in front of the player, so the next
// hand's stacks are already right.
Betting.creditWinnings(state, payouts);
@@ -359,6 +430,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
@@ -376,12 +500,19 @@ document.addEventListener("DOMContentLoaded", () => {
if (!ok) return;
}
// startNextHand moves the button on by one; say where it landed so the
// dealer can confirm the rotation without hunting for the badge.
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
const where = state.dedicatedDealer
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`;
// Say where the button landed so the dealer can confirm the rotation without
// hunting for the badge. Asked of the rotation itself rather than guessed at as
// "the next chair along" — which was only ever right at a table with nobody
// missing, and is the exact thing the dead button makes untrue.
const rotation = HoldemState.nextButtonRotation(state);
const nextDealer = rotation.buttonIndex !== null
? rotation.buttonIndex
: HoldemState.buttonSeatForDeal({ ...state, dealerSeatIndex: HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1) });
const where = rotation.deadButton
? `dead button on seat ${nextDealer + 1}`
: state.dedicatedDealer
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`;
const discarded = unresolved > Betting.EPSILON_CHIPS
? ` ${Money.format(unresolved)} taken off the table.`
: "";
@@ -450,6 +581,25 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
// The house's cut. Kept as a group rather than three loose fields, because
// migrate() guarantees the shape and half a rake object would be a table nobody
// could read back.
const readRake = () => {
const percent = Number(document.getElementById("rakePercentInput").value);
const cap = Number(document.getElementById("rakeCapInput").value);
state.rake = {
percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0,
cap: Number.isFinite(cap) && cap > 0 ? Money.round(cap) : null,
noFlopNoDrop: document.getElementById("rakeNoFlopInput").checked,
};
hooks.save(state);
Render.renderAll();
};
["rakePercentInput", "rakeCapInput"].forEach(id => {
document.getElementById(id).addEventListener("input", readRake);
});
document.getElementById("rakeNoFlopInput").addEventListener("change", readRake);
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
@@ -579,15 +729,99 @@ document.addEventListener("DOMContentLoaded", () => {
if (!ok) return;
HoldemState.clearHistory(state);
Render.historyFilter = "";
document.getElementById("handHistoryFilter").value = "";
hooks.save(state);
Render.renderAll();
Toast.show("Hand history cleared.", { duration: 2500 });
});
document.getElementById("saveTableBtn").addEventListener("click", () => {
const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = HoldemState.backupFileName();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
});
document.getElementById("loadTableBtn").addEventListener("click", () => {
document.getElementById("loadTableInput").click();
});
document.getElementById("loadTableInput").addEventListener("change", async e => {
const file = e.target.files && e.target.files[0];
// Cleared straight away so picking the same file twice still fires a change.
e.target.value = "";
if (!file) return;
let text;
try {
text = await file.text();
} catch {
Toast.show("That file couldn't be read.", { duration: 4000 });
return;
}
const result = HoldemState.fromBackup(text);
if (result.error) {
Toast.show(result.error, { duration: 5000 });
return;
}
// Loading replaces everything: stacks, buy-ins, the button, the log. Naming
// what is about to be lost is the difference between a dealer choosing that
// and discovering it — and the current table might be the live one.
const here = HoldemState.sessionStandings(state).length;
const incoming = HoldemState.sessionStandings(result.table).length;
const when = result.savedAt ? new Date(result.savedAt) : null;
const saidWhen = when && !Number.isNaN(when.getTime())
? ` It was saved ${when.toLocaleString()}.`
: "";
const ok = await Dialog.confirm({
title: "Replace this table?",
message:
`The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
`Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
`${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
`Undo will bring it back for a few seconds; after that it is gone.`,
confirmLabel: "Load it",
danger: true,
});
if (!ok) return;
runUndoable("loading a saved table", () => {
// Replace the contents rather than the reference: everything on the page
// holds a pointer to this object.
Object.keys(state).forEach(key => { delete state[key]; });
Object.assign(state, result.table);
});
});
// The filter is how the dealer looks at the log, not something about the table, so
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
// tab. Clearing the history clears it too, since there is nothing left to filter.
document.getElementById("handHistoryFilter").addEventListener("input", e => {
Render.historyFilter = e.target.value;
Render.renderHandHistory();
});
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
// Always the whole night. A filter is for reading the panel; an export that
// quietly dropped the hands scrolled past would be a record with holes in it,
// and nothing on the CSV would say so.
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
{ notes: state.notes });
if (Render.historyFilter) {
Toast.show(`Exported all ${state.handHistory.length} hands, not just the ${
state.handHistory.filter(h => Render.handMatchesFilter(h, Render.historyFilter)).length} shown.`,
{ duration: 3500 });
}
});
// The load happens on the first line of this file, long before there's a Toast to
@@ -63,6 +63,44 @@ const Money = {
// Anything the smallest chip can't make comes back as `remainder` rather than being
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
// problem for the dealer to solve, not a rounding error to hide.
// The smallest chip the table has said it plays with, in cents. Zero when no
// denominations are set, which means the tool has been told nothing about what
// chips exist and shouldn't pretend otherwise.
chipStepCents(legend) {
const values = (legend || [])
.map(chip => this.toCents(chip.value))
.filter(cents => cents > 0);
return values.length > 0 ? Math.min(...values) : 0;
},
// Rounds an amount up to something the table can actually build out of its chips.
//
// Up rather than down or nearest: these are bet sizes, and a pot-sized bet that
// has been shaved is no longer a pot-sized bet. Rounding up also can't drop an
// amount below a legal minimum, which rounding the other way can.
upToChip(amount, legend) {
const step = this.chipStepCents(legend);
if (step <= 0) return this.round(amount);
const cents = this.toCents(amount);
if (cents <= 0) return this.round(amount);
return this.fromCents(Math.ceil(cents / step) * step);
},
// And down, for money coming *out* of a pot rather than going into it.
//
// The rake is the one amount in this tool that leaves the table, so the direction
// of the rounding is a decision about whose money it is. Down: a house that can't
// make 40c out of its chips takes nothing rather than a dollar, and the players
// keep the difference. Being wrong in the players' favour is the only way to be
// wrong about this that nobody has to argue about.
downToChip(amount, legend) {
const step = this.chipStepCents(legend);
if (step <= 0) return this.round(amount);
const cents = this.toCents(amount);
if (cents <= 0) return 0;
return this.fromCents(Math.floor(cents / step) * step);
},
chipBreakdown(amount, legend) {
let cents = this.toCents(amount);
if (cents <= 0) return { chips: [], remainder: 0 };
@@ -232,6 +232,24 @@ const PotWizard = {
return wrap;
},
// What the house takes out of this pot, and what is left to push.
//
// Worked out here rather than at the end so the dealer sees it before anything
// moves: the summary is the last chance to disagree with the arithmetic, and a
// rake that only appeared in the hand log afterwards would be the tool taking
// money quietly.
rakeSplit() {
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
const sawFlop = (this.state.street || "preflop") !== "preflop";
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
},
renderSummary() {
const wrap = document.createElement("div");
wrap.className = "potWizardSummary";
@@ -240,6 +258,20 @@ const PotWizard = {
heading.textContent = "Final Payouts";
wrap.appendChild(heading);
const split = this.rakeSplit();
const rakeSet = Number((this.state.rake || {}).percent) > 0;
if (rakeSet && this.totals.size > 0) {
const line = document.createElement("p");
line.className = "potWizardRake";
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
line.textContent = split.rake > 0
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
: split.sawFlop
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
wrap.appendChild(line);
}
if (this.totals.size === 0) {
const empty = document.createElement("p");
empty.className = "fieldHint";
@@ -248,7 +280,7 @@ const PotWizard = {
} else {
const legend = (this.state && this.state.chipLegend) || [];
const list = document.createElement("ul");
for (const [id, amount] of this.totals.entries()) {
for (const { id, amount } of split.payouts) {
const li = document.createElement("li");
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
@@ -275,13 +307,12 @@ const PotWizard = {
applyBtn.className = "holdemBtn holdemBtnAccent";
applyBtn.textContent = "Start Next Hand";
applyBtn.addEventListener("click", () => {
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
// Recomputed rather than reused from the render: the same numbers, but
// taken at the moment the dealer commits rather than when they last
// looked at the screen.
const paid = this.rakeSplit();
this.close();
this.onFinished(payouts);
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
});
actions.appendChild(applyBtn);
@@ -197,6 +197,80 @@ const QuickSplit = {
});
},
// Chopping one of the pots listed above, without leaving the screen that worked it
// out. Starts closed and at as many ways as there are players who can win the pot,
// which is the common case; the stepper covers a chop between fewer of them, which
// is the other one.
//
// The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
// the main pot doesn't change what the side pot below it is saying.
addChopControl(row, amount, contenderCount) {
let ways = contenderCount;
let open = false;
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "unevenChopToggle";
const detail = document.createElement("div");
detail.className = "unevenChopDetail";
detail.hidden = true;
const draw = () => {
toggle.textContent = open ? "Hide chop" : "Chop";
toggle.setAttribute("aria-expanded", String(open));
detail.hidden = !open;
if (!open) return;
const result = SplitCalculator.split(amount, ways, this.chip);
detail.innerHTML = "";
const stepper = document.createElement("div");
stepper.className = "unevenChopWays";
stepper.appendChild(this.makeStepButton("−", () => {
if (ways > 2) { ways -= 1; draw(); }
}));
const label = document.createElement("span");
label.className = "unevenChopWaysLabel";
label.textContent = `${ways} ways`;
stepper.appendChild(label);
stepper.appendChild(this.makeStepButton("+", () => {
if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); }
}));
detail.appendChild(stepper);
const each = document.createElement("p");
each.className = "unevenChopEach";
each.textContent = `${Money.format(result.each)} each`;
detail.appendChild(each);
// Same wording as the main split, because it is the same rule and a dealer
// shouldn't have to notice they're reading a different screen.
if (result.valid && result.oddChips > 0) {
const odd = document.createElement("p");
odd.className = "unevenChopNote";
odd.textContent = result.oddChips === 1
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
`first ${result.oddChips} players left of the button`;
detail.appendChild(odd);
}
if (result.valid && result.dust > 0) {
const dust = document.createElement("p");
dust.className = "unevenChopNote unevenChopDust";
dust.textContent =
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
`pick a smaller denomination.`;
detail.appendChild(dust);
}
};
toggle.addEventListener("click", () => { open = !open; draw(); });
draw();
row.appendChild(toggle);
row.appendChild(detail);
},
updateUneven() {
const players = this.entries
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
@@ -218,7 +292,7 @@ const QuickSplit = {
}
empty.hidden = true;
const addRow = (label, amount, who, className) => {
const addRow = (label, amount, who, className, chopWays) => {
const row = document.createElement("div");
row.className = `unevenPot ${className || ""}`;
const name = document.createElement("span");
@@ -234,9 +308,19 @@ const QuickSplit = {
row.appendChild(value);
row.appendChild(eligible);
host.appendChild(row);
// Two players tying for a side pot is ordinary, and working out each one's
// share meant copying the amount into the other mode by hand — retyping a
// number the screen had just worked out, which is a transcription error
// waiting to happen with money on it. The chop happens here instead.
//
// Only offered where it means something: a pot with at least two people
// who can win it. Not the uncalled row, which goes back to one player, and
// not the total.
if (chopWays >= 2) this.addChopControl(row, amount, chopWays);
};
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
const total = players.reduce((sum, p) => sum + p.contribution, 0);
+456 -21
View File
@@ -33,6 +33,7 @@ const Render = {
this.renderNotes();
this.updatePotBadge();
this.renderSetupFields();
this.renderHeaderActions();
this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
@@ -47,6 +48,29 @@ const Render = {
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
const rake = this.state.rake || {};
document.getElementById("rakePercentInput").value = rake.percent || "";
document.getElementById("rakeCapInput").value = rake.cap ?? "";
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
// What the house has actually taken tonight, said where the rake is set rather
// than only in the bank check — a dealer who changes the percentage mid-session
// wants to see the running total right there.
const taken = Math.max(0, Number(this.state.rakeTaken) || 0);
const hint = document.getElementById("rakeTakenHint");
hint.hidden = taken <= 0;
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
@@ -82,24 +106,205 @@ const Render = {
// ---- Seats around the table (compact pucks) ----
seatPosition(index, count) {
return this.positionOnRing(index, count, 44, 42);
seatPosition(index, count, aspect = this.feltAspect()) {
return this.positionOnRing(index, count, 44, 42, aspect);
},
// How many times wider than tall the felt actually is, which is the one thing the
// ring needs from the layout and can't work out from percentages. Falls back to a
// circle when there's nothing to measure — the same answer the ring gave before it
// asked.
feltAspect() {
const box = this.ringBox();
return box ? box.width / box.height : 1;
},
// The box a seat's left/top percentages actually resolve against. Not the felt: on
// a phone the seat layer is inset by half a puck so the outermost seats don't hang
// off the screen, and measuring the felt instead put the ring's shape out by that
// inset — twelve seats at 640x360 were computed 90px apart and drawn 60px apart,
// which is the difference between "fine" and "overlapping".
ringBox() {
if (typeof document === "undefined") return null;
const layer = document.getElementById("seatsLayer") || document.getElementById("pokerTableWrap");
if (!layer) return null;
const box = layer.getBoundingClientRect();
return box.width && box.height ? box : null;
},
// Bets sit on the felt between their player and the pot, the way chips actually
// do — closer in than the seats, on the same spoke. The bets layer is inset to
// match the seat ring (see the CSS), so this one radius holds at every size.
betPosition(index, count) {
return this.positionOnRing(index, count, 27, 25);
//
// The angle is the seat's own rather than one walked again round a tighter ellipse.
// The two answers are within half a degree of each other, so this is not a fix for
// anything visible — it is just the honest way to say "on that player's spoke",
// and it saves the second walk.
betPosition(index, count, aspect = this.feltAspect()) {
const seat = this.seatPosition(index, count, aspect);
return {
left: 50 + (seat.left - 50) * (27 / 44),
top: 50 + (seat.top - 50) * (25 / 42),
};
},
positionOnRing(index, count, rx, ry) {
const angle = -90 + (360 / count) * index;
const radians = (angle * Math.PI) / 180;
return {
left: 50 + rx * Math.cos(radians),
top: 50 + ry * Math.sin(radians),
};
// Seats spaced evenly around the rim — by the distance between them, not by the
// angle they sit at.
//
// Equal angles are only equal spacing on a circle. The felt is an ellipse, and a
// phone on its side makes it a very flat one: 601x126 at 640x360. Ten seats at 36°
// apart then put four of them down each steep side, where 36° of turn is barely any
// distance — seats 3 and 4 came out at the same x and 20px apart vertically, each
// puck 72px tall. Their centres landed on each other, so tapping a player got their
// neighbour, and the layout audit had been calling it at 640x360 and 667x375.
//
// Walking the perimeter instead puts the seats where a real table has them: along
// the two long rails, one at each end. The arc length is summed numerically because
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
// few hundred pixels long, and it runs once per render.
positionOnRing(index, count, rx, ry, aspect = 1) {
return this.ringPositions(count, rx, ry, aspect)[index];
},
// Past this much wider than tall, the rim stops being an ellipse and becomes a
// stadium — two straight rails with a semicircular cap at each end.
//
// An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with
// the bar grown by a side pot the felt is 616x96, and six seats came out with two
// of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px:
// the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so
// an end holds one seat and the rest of the table is rail — which is how a real
// poker table is built, and why it is shaped that way.
//
// Only where it is needed. Anything rounder than this keeps the ellipse it has
// always had: measured, the cut-off lands between a landscape phone (4.4 and up)
// and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone
// upright), so no currently-working table changes shape.
STADIUM_RATIO: 2.5,
// Seats spaced evenly around whichever rim this table has, as percentages.
ringPositions(count, rx, ry, aspect = 1) {
if (count <= 0) return [];
const halfWidth = rx * aspect;
const halfHeight = ry;
if (halfWidth <= halfHeight * this.STADIUM_RATIO) {
return this.ringAngles(count, rx, ry, aspect).map(angle => ({
left: 50 + rx * Math.cos(angle),
top: 50 + ry * Math.sin(angle),
}));
}
return this.stadiumPositions(count, halfWidth, halfHeight, aspect);
},
// The same walk, around a stadium instead. Its perimeter has a closed form, so
// this needs no sampling: two rails of 2(A-B) and two caps of piB.
stadiumPositions(count, halfWidth, halfHeight, aspect) {
const rail = halfWidth - halfHeight;
const cap = Math.PI * halfHeight;
const perimeter = 4 * rail + 2 * cap;
return Array.from({ length: count }, (_, seat) => {
// Clockwise from the top centre, so seat one sits where it always has.
let walked = (perimeter * seat) / count;
let x;
let y;
if (walked <= rail) { // top rail, going right
x = walked;
y = -halfHeight;
} else if (walked <= rail + cap) { // right cap
const turned = (walked - rail) / halfHeight;
x = rail + halfHeight * Math.sin(turned);
y = -halfHeight * Math.cos(turned);
} else if (walked <= 3 * rail + cap) { // bottom rail, going left
x = rail - (walked - rail - cap);
y = halfHeight;
} else if (walked <= 3 * rail + 2 * cap) { // left cap
const turned = (walked - 3 * rail - cap) / halfHeight;
x = -rail - halfHeight * Math.sin(turned);
y = halfHeight * Math.cos(turned);
} else { // top rail, back to the start
x = -rail + (walked - 3 * rail - 2 * cap);
y = -halfHeight;
}
return { left: 50 + x / aspect, top: 50 + y };
});
},
RING_STEPS: 720,
ringCache: {},
// The closest two neighbouring seats come to each other, straight line, in pixels.
//
// Rim length divided by seat count was the first answer and it is the wrong one:
// it measures the walk between two seats rather than the gap between them, and on
// a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
// each and sat 56px apart, because at the ends of a long oval the rim doubles back
// and two seats a long walk apart are stacked one above the other.
closestSeatGapPx(count, box = this.ringBox()) {
if (count < 2 || !box || !box.width || !box.height) return Infinity;
const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
x: (seat.left / 100) * box.width,
y: (seat.top / 100) * box.height,
}));
return points.reduce((closest, point, i) => {
const next = points[(i + 1) % points.length];
return Math.min(closest, Math.hypot(point.x - next.x, point.y - next.y));
}, Infinity);
},
// A puck is 76px across, so neighbours whose centres are closer than that overlap
// by definition; a few pixels more keeps them from touching. Under it the pucks
// come down a size rather than the table pretending it has room it hasn't.
SEAT_ROOM_PX: 84,
// Called on every render and again whenever the felt is resized — the action bar
// growing mid-hand shortens the table, and the seats have to answer that without
// waiting for the next render to come along.
updateSeatDensity() {
const layer = document.getElementById("seatsLayer");
if (!layer || !this.state) return;
layer.classList.toggle("seatsTight",
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
},
// The angles that cut the rim into `count` equal lengths, starting at the top.
// Every seat on a ring wants the same answer, so it is worked out once per shape
// and kept — the ring only changes when the table is resized or the window is.
ringAngles(count, rx, ry, aspect) {
if (count <= 0) return [];
const key = `${count}:${rx}:${ry}:${aspect.toFixed(3)}`;
if (this.ringCache[key]) return this.ringCache[key];
const start = -Math.PI / 2;
// Percentages of the width and of the height are not the same distance, so the
// horizontal radius is scaled into the vertical one's units before measuring.
const a = rx * aspect;
const b = ry;
const step = (2 * Math.PI) / this.RING_STEPS;
const cumulative = [0];
for (let i = 1; i <= this.RING_STEPS; i++) {
const from = start + (i - 1) * step;
const to = start + i * step;
const dx = a * (Math.cos(to) - Math.cos(from));
const dy = b * (Math.sin(to) - Math.sin(from));
cumulative.push(cumulative[i - 1] + Math.hypot(dx, dy));
}
const perimeter = cumulative[this.RING_STEPS];
const angles = [];
let walked = 0;
for (let seat = 0; seat < count; seat++) {
const wanted = (perimeter * seat) / count;
while (walked < this.RING_STEPS && cumulative[walked + 1] < wanted) walked++;
// Interpolate inside the step the seat falls in, so the answer doesn't
// jump in 0.5° increments.
const spanned = cumulative[walked + 1] - cumulative[walked];
const into = spanned > 0 ? (wanted - cumulative[walked]) / spanned : 0;
angles.push(start + (walked + into) * step);
}
this.ringCache[key] = angles;
return angles;
},
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
@@ -171,8 +376,15 @@ const Render = {
renderSeats() {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
this.updateSeatDensity();
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
// fixing. Once it's the dead button it means the opposite — it's exactly where
// it belongs — so it gets marked rather than looking like the thing to correct.
const deadButtonHere = this.state.deadButton &&
HoldemState.deadPositionsApply(this.state) &&
!this.state.seats[dealerIndex].inHand;
this.state.seats.forEach((player, index) => {
const node = template.content.firstElementChild.cloneNode(true);
@@ -201,8 +413,11 @@ const Render = {
// of nine identical buttons and couldn't tell which seat held the real one.
// The label now says whose it is, or what pressing it would do.
const who = (player.name || "").trim() || `seat ${index + 1}`;
dealerChip.classList.toggle("dealerChipDead", hasButton && deadButtonHere);
const chipLabel = hasButton
? `${who} has the dealer button`
? (deadButtonHere
? `Dead button on empty seat ${index + 1} — its player left out of the blinds`
: `${who} has the dealer button`)
: `Give the dealer button to ${who}`;
dealerChip.setAttribute("aria-label", chipLabel);
dealerChip.title = chipLabel;
@@ -227,6 +442,14 @@ const Render = {
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
// A straddle is a blind too, and the seat wearing it is the one the action
// comes back to. Marked from what the deal actually posted rather than from
// the count, so the badge can't outlive the chips it stands for — before
// the cards there is nothing out there yet.
const straddleBadge = node.querySelector(".strBadge");
const straddled = (this.state.postedStraddles || []).includes(index);
straddleBadge.classList.toggle("visible", this.state.bettingOpen && straddled);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
@@ -515,13 +738,41 @@ const Render = {
this.updatePotBadge();
this.renderSeatTotal(form, player);
this.refreshPuck(index);
// The editor deliberately refreshes narrowly rather than redrawing everything —
// a full render on every keystroke would take the name field out from under the
// dealer mid-word. But the action bar answers "is there a table here at all",
// and seating somebody is exactly what changes that answer. Without this it
// stayed hidden until something else happened to redraw: two players seated, a
// dealable hand, and a blank bar until the page was reloaded.
//
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
// anyway, and nothing inside it can hold focus while the sidebar has it.
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
// Putting a name on an empty chair is somebody sitting down, so they get
// dealt in. It used to be two separate acts, and the second one was easy to
// miss: a dealer setting up typed six names, got six seats that still
// weren't in the hand, and the action bar hid itself because by its reckoning
// nobody was playing. Six people at the table and a blank screen.
//
// Only for a chair that was empty — no name and not dealt in. Renaming
// somebody who is sitting out is a correction, not an arrival, and dealing
// them back in over a typo would be the tool overruling the dealer.
const wasEmpty = !player.name.trim() && !player.inHand;
player.name = nameInput.value;
if (wasEmpty && player.name.trim()) {
player.inHand = true;
player.folded = false;
const inHandBox = form.querySelector(".inHandCheckbox");
if (inHandBox) inHandBox.checked = true;
const foldBox = form.querySelector(".foldCheckbox");
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
}
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
@@ -772,7 +1023,14 @@ const Render = {
const template = document.getElementById("standingsRowTemplate");
standings.forEach(row => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".standingsName").textContent = row.name;
// Somebody who has gone home is still on the list — they played, and the
// box took their cash — but the number beside them is money already handed
// over, not chips waiting to be counted. Marked, or a dealer settling up at
// 2am pays them twice.
node.classList.toggle("standingsGone", !!row.cashedOut);
node.querySelector(".standingsName").textContent = row.cashedOut
? `${row.name} · paid out`
: row.name;
node.querySelector(".standingsIn").textContent = Money.format(row.in);
node.querySelector(".standingsWon").textContent = Money.format(row.won);
@@ -781,9 +1039,15 @@ const Render = {
netEl.classList.toggle("netUp", row.net > 0);
netEl.classList.toggle("netDown", row.net < 0);
// What they'd actually be handed if the night ended now: chips in front of
// them less the cash they gave the banker. This was computed and written to
// the CSV all along, but never shown to the person doing the paying out.
// The cash to count out, which is a plain amount and never signed — this
// is the one the banker's hands follow. It sat only in the CSV: on screen
// the column headed "Cash out" held the difference against their buy-in
// instead, so a player up $140 on a $340 stack read as "$140" at the exact
// moment somebody was counting money onto the table.
node.querySelector(".standingsChips").textContent = Money.format(row.chipsInFront);
// And whether that leaves them up or down, which is the question the rest
// of the table asks and the one the old column was actually answering.
const cashEl = node.querySelector(".standingsCashOut");
cashEl.textContent = Money.formatSigned(row.cashPosition);
cashEl.classList.toggle("netUp", row.cashPosition > 0);
@@ -794,9 +1058,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
@@ -912,20 +1230,77 @@ const Render = {
}
}
// Same idea for the house's cut, and the more important of the two: a raked
// game moves chips off the table every single hand, so without this line the
// check reads as a shortfall that grows all night.
const rakedRow = document.getElementById("bankRakedRow");
if (rakedRow) {
rakedRow.hidden = !(bank.raked > 0);
if (bank.raked > 0) {
document.getElementById("bankRaked").textContent = Money.format(bank.raked);
}
}
// Chips handed back to people who have gone home. The third way money leaves
// this table on purpose, and the one a dealer is asked about most: "where did
// Ann's four hundred go?"
const paidRow = document.getElementById("bankPaidOutRow");
if (paidRow) {
paidRow.hidden = !(bank.paidOut > 0);
if (bank.paidOut > 0) {
document.getElementById("bankPaidOut").textContent = Money.format(bank.paidOut);
}
}
const verdict = document.getElementById("bankReconcileVerdict");
// What has left the table on purpose, whichever way it went.
const accountedFor = [
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
bank.paidOut > 0 ? `${Money.format(bank.paidOut)} paid out to players who have left` : null,
].filter(Boolean);
if (bank.balanced) {
verdict.textContent = bank.discarded > 0
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
verdict.textContent = accountedFor.length > 0
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
// Name them. "Somebody's rebuy wasn't recorded" is true and useless; the
// dealer's next move is to go to a particular seat, and the tool knows
// which. The commonest cause by far is the table's own starting stack:
// seats holding chips that were never bought, because "everyone starts
// with 200" is how a night opens.
const owing = HoldemState.seatsOwingCash(this.state);
const names = owing.map(row => row.name);
const who = names.length === 1
? names[0]
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
verdict.textContent = owing.length > 0
? `${Money.format(bank.difference)} more in chips than cash taken. ${who} ` +
`${owing.length === 1 ? "is" : "are"} holding chips with no buy-in written down.`
: `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
} else {
verdict.textContent =
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
}
verdict.classList.toggle("bankBalanced", bank.balanced);
verdict.classList.toggle("bankOff", !bank.balanced);
// One tap for the seats that are only holding the table's starting stack —
// which is the dealer's own instruction, said once in the setup panel, rather
// than the tool inventing cash. Anybody else's unrecorded rebuy is named in the
// sentence above and left to them, because only they know what it was.
const record = document.getElementById("bankRecordBuyInsBtn");
if (record) {
const starting = HoldemState.seatsOwingCash(this.state).filter(row => row.fromStartingStack);
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
record.hidden = starting.length === 0;
if (starting.length > 0) {
record.textContent = `Take ${Money.format(total)} from ${starting.length} ${starting.length === 1 ? "player" : "players"}`;
record.title = starting.length === 1
? `Write down ${Money.format(total)} taken from ${starting[0].name}`
: `Write down the starting stack as cash taken from ${starting.map(row => row.name).join(", ")}`;
}
}
},
// ---- Hand counter + history ----
@@ -958,9 +1333,50 @@ const Render = {
newest ? newest.handNumber : "",
newest ? newest.timestamp : "",
newest ? newest.pot : "",
// Typing in the filter changes the list without changing the history, and
// the redraw is skipped when the signature matches — leave this out and the
// box takes input and does nothing.
this.historyFilter || "",
].join("|");
},
// What the dealer opens the log for is one hand: the one somebody is arguing
// about. A night runs to sixty or more, five at a time in the panel, so finding it
// meant scrolling and reading every row. A name, an amount, or a hand number
// narrows it instead.
//
// Matching is deliberately loose. "fay" finds Fay whatever case it's typed in;
// "190" finds a $190 pot, a $190 payout, or hand 190, because a dealer half
// remembering an amount shouldn't have to know which of those they mean; "#12"
// pins it to the hand number, for when they do.
handMatchesFilter(entry, query) {
if (!query) return true;
const term = query.trim().toLowerCase();
if (!term) return true;
const handNumber = String(entry.handNumber || "");
if (term.startsWith("#")) return handNumber.startsWith(term.slice(1));
// Only what the row itself shows. Matching the hand's full ledger as well was
// the first attempt, on the reasoning that a dealer might hunt for the hand
// that broke somebody by the loser's name — but the ledger holds everyone who
// put a chip in, so at a table where nobody folds it matched every hand.
// Measured on a seven-handed night: "Fay" returned 60 of 60. Searching text the
// panel doesn't display is a filter that looks broken even when it's right.
const labels = (entry.payouts || []).map(p => String(p.label || "").toLowerCase()).join(" ");
if (labels.includes(term)) return true;
// Amounts, matched on the digits so "$190", "190" and "1,90" all behave.
const digits = term.replace(/[^0-9.]/g, "");
if (digits) {
const amounts = [entry.pot, ...(entry.payouts || []).map(p => p.amount)]
.map(a => String(Money.round(Number(a) || 0)));
if (amounts.some(a => a.startsWith(digits))) return true;
if (handNumber === digits) return true;
}
return false;
},
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
// full re-render runs after every fold, call and raise. The log only changes when a
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
@@ -979,10 +1395,29 @@ const Render = {
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
const query = this.historyFilter || "";
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
// How much of the night is being hidden. Without it a filter that matches one
// hand looks exactly like a table that only played one, which is the sort of
// thing a dealer reads wrong at 2am with somebody leaning over the rail.
const count = document.getElementById("handHistoryCount");
if (count) {
count.hidden = !query || history.length === 0;
count.textContent = `${shown.length} of ${history.length}`;
}
const noMatch = document.getElementById("handHistoryNoMatch");
if (noMatch) {
noMatch.hidden = !(query && history.length > 0 && shown.length === 0);
noMatch.textContent = `No hand matches “${query.trim()}”. The log still has all ${history.length}.`;
}
const filterBox = document.getElementById("handHistoryFilter");
if (filterBox) filterBox.hidden = history.length === 0;
// Built off-document so the panel is laid out once instead of once per row.
const fragment = document.createDocumentFragment();
const template = document.getElementById("handHistoryRowTemplate");
history.forEach(entry => {
shown.forEach(entry => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
@@ -47,6 +47,28 @@ 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 = !(player.name || "").trim() &&
!(Number(player.buyIns) > 0) && !(Number(player.stack) > 0);
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) {
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
+595 -17
View File
@@ -81,6 +81,53 @@ const HoldemState = {
// button order is the wrong answer often enough that it has to be
// overridable. Cleared at the start of every hand.
blindOverride: null,
// Who actually posted, the last time cards went out — { sbIndex, bbIndex }.
// The next hand's rotation is worked out from this rather than from the
// seating as it stands, because by then the people it needs to know about
// are exactly the ones who have got up and gone.
postedBlinds: null,
// Who last bet or raised, on this street and anywhere in the hand. Kept
// apart because the showdown rule needs both: normally the aggressor of the
// final betting round shows first, but a round nobody could bet in — chips
// already all-in — falls back to the last one who could.
streetAggressorIndex: null,
handAggressorIndex: null,
// Player ids that were dealt in the last hand actually dealt. What makes a
// seat recognisable as having *just* joined — new to the table, or back
// from sitting out — which is the whole question behind owing a blind.
dealtLastHand: [],
// Ids the dealer has said will buy in past the blinds this hand, by posting
// the big blind rather than waiting for it. Cleared every hand: it's a
// decision about one deal, not a standing arrangement.
postingIn: [],
// How many straddles are on for the hand about to be dealt. A straddle is
// announced hand by hand, so this is cleared with every other one-deal
// decision rather than standing until somebody turns it off.
straddleCount: 0,
// What the house takes, if it takes anything. Percent zero is a game that
// doesn't rake, which is nearly all of them and the default.
rake: { percent: 0, cap: null, noFlopNoDrop: true },
// What it has taken so far tonight. A session total, not a per-hand one:
// it is the number the box is checked against at the end.
rakeTaken: 0,
// People who have taken their chips and gone. Kept for the night rather
// than forgotten with the seat: the box took their cash and paid it back
// out, and both halves have to stay on the record or the bank check reads
// as a shortfall for the rest of the evening.
cashedOut: [],
// People standing behind the chairs. A dealer at a full table keeps this
// list on a napkin; keeping it here means the tool can offer the next seat
// that opens to whoever has been waiting longest.
waitingList: [],
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
postedStraddles: [],
// Set when somebody leaves out of the blinds and the rotation has to hold
// to the seats to keep the big blind moving one player at a time. The
// button then sits on the chair they vacated, and no small blind is posted.
deadButton: false,
deadSmallBlind: false,
};
},
@@ -140,13 +187,18 @@ const HoldemState = {
return this.createDefault();
},
load() {
let raw = null;
try {
raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
// Everything a table has to be put through before it can be trusted, whichever
// direction it arrived from.
//
// Pulled out of load() so a table read from a file goes through exactly the same
// checks as one read from storage. A second, looser path for imports is how a
// format grows two meanings of the same field.
//
// Returns null for anything that isn't a table at all; every other repair is
// silent and forward, the way a save from an older version is treated.
migrate(parsed) {
if (!parsed || !Array.isArray(parsed.seats)) return null;
{
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
@@ -155,11 +207,59 @@ const HoldemState = {
// them in rather than making the dealer reset a table mid-session.
const defaults = this.createDefault();
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
"actingSeatIndex", "blindOverride"]
"actingSeatIndex", "blindOverride", "postedBlinds",
"streetAggressorIndex", "handAggressorIndex"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
// A table saved before the dead button existed has neither flag. False is
// the right guess for both: it means the rotation carries on the ordinary
// way, which is what that table was already doing.
parsed.deadButton = parsed.deadButton === true;
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
// An old save has no record of who was dealt last hand. Empty is the
// honest answer — it reads as "this table hasn't played yet", so the first
// hand after reopening treats nobody as having just walked up, rather than
// asking the whole table to post.
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
// None on is the right reading of a save from before straddles existed:
// whatever is in front of those players is already in their committed
// chips, and nothing is owed on top of it.
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
// A table saved before the house took anything takes nothing, which is
// also the right answer for one that never will.
const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
parsed.rake = {
percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
parsed.waitingList = Array.isArray(parsed.waitingList)
? parsed.waitingList
.map(row => (typeof row === "string" ? { id: null, name: row } : row))
.filter(row => row && String(row.name || "").trim())
.map(row => ({
id: row.id || this.makeId("waiting"),
name: String(row.name).trim().slice(0, 24),
addedAt: Number(row.addedAt) || 0,
}))
: [];
parsed.cashedOut = Array.isArray(parsed.cashedOut)
? parsed.cashedOut
.filter(row => row && row.id)
.map(row => ({
id: row.id,
name: String(row.name || "").slice(0, 40),
buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)),
chips: Math.max(0, Money.round(Number(row.chips) || 0)),
at: Number(row.at) || 0,
}))
: [];
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
// progress. Anything already committed, or anyone already on the clock,
@@ -178,12 +278,85 @@ const HoldemState = {
});
return parsed;
}
},
load() {
let raw = null;
try {
raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const migrated = this.migrate(JSON.parse(raw));
if (!migrated) return this.failedLoad(raw);
return migrated;
} catch {
// Malformed JSON — a truncated write, another tab, a browser hiccup.
return this.failedLoad(raw);
}
},
// ---- Taking the table off this device ------------------------------------
//
// A night lives in one browser's localStorage and nowhere else. A dealer whose
// phone dies, or who wants to carry on from the tablet after the break, loses the
// stacks, the buy-ins, where the button is and the whole hand log — and the CSV
// export can't bring any of that back, because it records what happened rather
// than where the table stands.
//
// So the table can be written out as a file and read back in. Same shape that goes
// into storage, wrapped in enough to recognise it later.
BACKUP_KIND: "casino.holdem.table",
BACKUP_VERSION: 1,
toBackup(state) {
return JSON.stringify({
kind: this.BACKUP_KIND,
version: this.BACKUP_VERSION,
savedAt: new Date().toISOString(),
table: state,
}, null, 2);
},
// Reads a file back. Returns { table } on success, or { error } with something a
// dealer can act on — never a half-loaded table, because the failure mode there is
// a night that looks restored and isn't.
fromBackup(text) {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return { error: "That file isn't a saved table — it couldn't be read as JSON." };
}
if (!parsed || typeof parsed !== "object") {
return { error: "That file isn't a saved table." };
}
// A bare table, from someone who copied the storage value out by hand, is
// still a table. The envelope is a convenience, not a password.
const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
return { error: "That file is a save from something else, not a Hold'em table." };
}
const migrated = this.migrate(table);
if (!migrated) {
return { error: "That file doesn't have a table in it — no seats were found." };
}
if (parsed.version > this.BACKUP_VERSION) {
// Newer than this build understands. Loading it anyway risks reading a
// field that has changed meaning, and the dealer would find out from the
// money, so say so instead.
return { error: "That table was saved by a newer version of this tool. Update, then load it." };
}
return { table: migrated, savedAt: parsed.savedAt || null };
},
// What the file is called. Dated, because a dealer who saves twice in a night
// should not have to guess which is which.
backupFileName(now = new Date()) {
const pad = n => String(n).padStart(2, "0");
return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
`-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
},
// True once a save has failed. The UI reads this to keep saying so, because a
// toast is a moment and this is a fact about the rest of the night.
saveFailed: false,
@@ -327,6 +500,10 @@ const HoldemState = {
// sits on nobody most hands — and that isn't only untidy: position and blinds are
// supposed to come round to each player at the same rate, and they don't if the
// button is rotating past chairs nobody is sitting in.
//
// The fallback, now. nextButtonRotation is what a hand that was actually dealt
// rotates by; this is for the cases it has nothing to work from — a fresh table,
// a hand never dealt, a dealer pressing Next Hand twice to pass the button on.
advanceButton(state) {
for (let step = 1; step <= state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
@@ -365,6 +542,10 @@ const HoldemState = {
buttonSeatForDeal(state) {
const seated = state.seats[state.dealerSeatIndex];
if (seated && seated.inHand) return state.dealerSeatIndex;
// A button left on an empty chair on purpose is the dead button, and moving it
// to a player is precisely what it's there to stop. Only a button that has been
// stranded by accident gets rescued.
if (state.deadButton && this.deadPositionsApply(state)) return state.dealerSeatIndex;
for (let step = 0; step < state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) return index;
@@ -387,6 +568,13 @@ const HoldemState = {
return o;
},
// A dead button and a dead small blind are three-handed-and-up ideas. Down to two
// players the button *is* the small blind and the blinds simply alternate, so
// whatever the last rotation left behind stops applying.
deadPositionsApply(state) {
return state.seats.filter(p => p.inHand).length >= 3;
},
// Who posts what. Heads-up is its own rule: with two players the button posts the
// small blind, which is the opposite of the usual order.
getBlindSeats(state) {
@@ -418,11 +606,88 @@ const HoldemState = {
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
}
// The small blind is dead: whoever would have moved into it has left the table,
// and the seat stays empty for a hand rather than dragging the big blind
// forward an extra place. One player gets a free hand out of position for it;
// that's the price of nobody being charged two big blinds in an orbit.
if (state.deadSmallBlind && this.deadPositionsApply(state)) {
return { dealerIndex, sbIndex: null, bbIndex: live[0], headsUp: false, deadSmallBlind: true };
}
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
},
// Seats about to be dealt a hand they haven't paid for.
//
// Somebody who walks up and sits down between the button and the big blind has had
// the blinds go past them. Dealing them in there is a free hand in late position —
// which is exactly what posting is for, and it comes out of everybody else's night
// a couple of dollars at a time. At a real table they either post the big blind to
// come straight in, or they wait for it to reach them.
//
// The seat that *is* the big blind this hand isn't owed anything: they're paying.
// Neither is anyone at a table that hasn't dealt a hand yet, where everybody is new
// and nobody has been skipped.
blindDebtors(state, bbIndex) {
const played = Array.isArray(state.dealtLastHand) && state.dealtLastHand.length > 0;
if (!played) return [];
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player, index }) =>
player.inHand &&
index !== bbIndex &&
!state.dealtLastHand.includes(player.id))
.map(({ index }) => index);
},
// Where the button goes for the next hand, and whether anything is left dead.
//
// Ordinarily it follows the small blind round — the next player along, which is
// what it has always done. The case that needs care is somebody getting up out of
// the blinds. Passing the button to the next *player* then pulls the whole blind
// order forward a place: the player after the empty chair is never asked for a big
// blind at all, and the one after them is asked an orbit early. Holding the
// rotation to the seats instead is the dead button, and getting it wrong costs a
// real player a real big blind.
//
// Worked out from who actually posted last hand, not from the seating as it stands,
// because by now the people it needs to know about are the ones who have gone.
nextButtonRotation(state) {
const posted = state.postedBlinds;
const inRange = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i];
const here = i => inRange(i) && state.seats[i].inHand;
// Nothing to hold to: a fresh table, a hand that was never dealt, or one where
// the blinds were named by hand. And heads-up has its own rule — with two
// players the button is the small blind, so there is no position to leave dead.
if (!posted || !inRange(posted.sbIndex) || !inRange(posted.bbIndex) || !this.deadPositionsApply(state)) {
return { buttonIndex: null, deadButton: false, deadSmallBlind: false };
}
// Both still sitting there is the ordinary case, and this comes out as the next
// player along — the same answer as passing the button, arrived at from the
// blinds so that the unusual case doesn't need a separate path.
return {
buttonIndex: posted.sbIndex,
deadButton: !here(posted.sbIndex),
deadSmallBlind: !here(posted.bbIndex),
};
},
startNextHand(state) {
this.advanceButton(state);
const rotation = this.nextButtonRotation(state);
if (rotation.buttonIndex === null) {
this.advanceButton(state);
state.deadButton = false;
state.deadSmallBlind = false;
} else {
state.dealerSeatIndex = rotation.buttonIndex;
state.deadButton = rotation.deadButton;
state.deadSmallBlind = rotation.deadSmallBlind;
}
// Spent. Pressing Next Hand again without dealing is a dealer passing the
// button along, not a second rotation off the same hand.
state.postedBlinds = null;
// Cards aren't out and nothing is posted until the dealer says so.
state.bettingOpen = false;
state.actingSeatIndex = null;
@@ -433,6 +698,13 @@ const HoldemState = {
// the next one would freeze the blinds on two players for the rest of the
// night, which is the opposite of what naming them once is for.
state.blindOverride = null;
// Posting to come in is a decision about one deal. Carrying it forward would
// charge somebody an extra big blind every hand for the rest of the night.
state.postingIn = [];
// So is a straddle. It is announced hand by hand at a table, and a straddle
// that stayed on would take chips off somebody who never said the word.
state.straddleCount = 0;
state.postedStraddles = [];
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
@@ -452,11 +724,15 @@ const HoldemState = {
// Must be called before startNextHand() clears the seats: the per-player `ledger`
// it stores alongside the payouts is what session standings are summed from later,
// and it needs this hand's contributions while they're still on the table.
recordHand(state, payouts) {
recordHand(state, payouts, rake = 0) {
if (typeof state.handNumber !== "number") state.handNumber = 1;
if (!Array.isArray(state.handHistory)) state.handHistory = [];
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
// The pot as it was, house cut included — the number the table saw in the
// middle. What each player took is separately recorded and is net of it, so
// the two together say where every chip went.
const taken = Money.round(Math.max(0, Number(rake) || 0));
const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken);
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
const ledger = state.seats
.map((player, index) => ({
@@ -471,6 +747,7 @@ const HoldemState = {
handNumber: state.handNumber,
timestamp: Date.now(),
pot,
rake: taken,
payouts,
ledger,
});
@@ -506,6 +783,15 @@ const HoldemState = {
// Cash bought in and chips currently in front don't come from the hand log at
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
const seatById = new Map(state.seats.map(p => [p.id, p]));
// And for anyone who has gone home, off the record their cash-out left behind.
// Without this they read as a player who bought in for nothing and is holding
// nothing, in the very list the dealer settles the night from.
const goneById = new Map((state.cashedOut || []).map(row => [row.id, row]));
goneById.forEach((row, id) => {
if (totals.has(id)) return;
totals.set(id, { id, name: row.name, in: 0, won: 0 });
});
// Anyone who has money in the game belongs in the settle-up, whether or not
// they've been in a logged hand yet. Building the list from the hand log alone
@@ -517,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;
@@ -527,8 +813,16 @@ const HoldemState = {
return Array.from(totals.values())
.map(t => {
const seat = seatById.get(t.id);
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
const gone = goneById.get(t.id);
const boughtIn = seat
? Money.round(Number(seat.buyIns) || 0)
: (gone ? gone.buyIns : 0);
// What a departed player is "holding" is what the box handed them on
// the way out. It is no longer on the table, so it is marked as paid
// rather than counted again at the end of the night.
const chipsNow = seat
? this.chipsHeldBy(state, seat)
: (gone ? gone.chips : 0);
// Chips already pushed into the hand in progress are still this
// player's money until it's won. Leaving them out makes the cash
// position disagree with the hand log for as long as a hand is open —
@@ -551,6 +845,18 @@ const HoldemState = {
net: Money.round(t.won - t.in),
boughtIn,
chipsNow,
// Everything still theirs: the stack in front of them plus whatever
// is out in the middle for the hand in progress. This is the number
// a banker counts out when somebody leaves, and it was the one
// figure the standings never showed — the column called "Cash out"
// held the difference against their buy-in, so a player up $140 on
// a $340 stack read as "$140" at the exact moment cash changed
// hands.
chipsInFront: Money.round(chipsNow + inFrontOfThem),
// Set once they have taken their chips and left, so the panel and
// the export can say so rather than showing a player still sitting
// there with a stack.
cashedOut: !!gone && !seat,
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
notes,
};
@@ -583,12 +889,274 @@ 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 }) => !(player.name || "").trim() &&
!(Number(player.buyIns) > 0) && !(Number(player.stack) > 0))
.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 ((seat.name || "").trim() || Number(seat.buyIns) > 0 || Number(seat.stack) > 0) {
return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
}
const entry = this.removeFromWaitingList(state, id);
if (!entry) return { ok: false, reason: "They aren't on the list any more." };
seat.name = entry.name;
// Not dealt in and holding nothing: the next thing that happens is the dealer
// taking their money, and Add chips is what says so.
seat.inHand = false;
return { ok: true, name: entry.name, index };
},
// ---- Somebody leaving with their chips ----
//
// The most ordinary thing that happens at a home game after midnight, and the tool
// had nothing for it. A dealer's only way to free the chair was to wipe the seat by
// hand, and then the bank check — which adds up cash taken against chips on the
// table — declared the departing player's whole stack missing: "someone has paid
// for chips they haven't been given", for the rest of the night. Wiping their
// buy-in as well silenced it, at the price of losing their night from the standings
// and the file the dealer hands over.
//
// Cashing out is two facts, and both have to survive the empty chair: the box took
// their cash, and the box paid their chips back out.
// Can this seat be cashed out right now?
canCashOut(state, index) {
const player = state.seats[index];
if (!player) return { ok: false, reason: "There's nobody in that seat." };
const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0));
if (inPot > 0) {
return {
ok: false,
// Paying someone out with money still in the middle would either take
// chips out of a pot other people can win, or hand them chips twice.
reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`,
};
}
return { ok: true };
},
// What the dealer counts out, and what the seat's night comes to.
cashOutPreview(state, index) {
const player = state.seats[index];
if (!player) return null;
// Betting owns the answer to "what is this seat holding" — including the rule
// that a seat which has never been given a stack is holding the table's
// starting one. Asking it rather than working it out again here: the first
// draft of this did, read `Number(null)` as a perfectly good zero, and offered
// to pay a player nothing.
const chips = Money.round(Math.max(0, Betting.stackOf(state, player)));
const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0));
return {
index,
name: this.displayName(player, index),
chips,
boughtIn,
// Their result for the night, in the direction a banker says it: positive
// means the box is paying out more than it took from them.
net: Money.round(chips - boughtIn),
breakdown: Money.formatChipBreakdown(chips, state.chipLegend),
};
},
// Hand them their chips and free the chair, keeping both halves on the record.
cashOut(state, index) {
const allowed = this.canCashOut(state, index);
if (!allowed.ok) return allowed;
const preview = this.cashOutPreview(state, index);
const leavingId = state.seats[index].id;
if (!Array.isArray(state.cashedOut)) state.cashedOut = [];
state.cashedOut.push({
id: leavingId,
name: preview.name,
buyIns: preview.boughtIn,
chips: preview.chips,
at: Date.now(),
});
// The chair opens for the next player. A fresh id, because the person who had
// it has gone and their night is now filed under the old one — reusing it would
// merge two people's results the moment somebody new sits down.
const seat = this.makePlayer();
state.seats[index] = seat;
// Anyone owed a hand by this seat is no longer owed one.
state.postingIn = (state.postingIn || []).filter(id => id !== leavingId);
return { ok: true, ...preview };
},
// ---- The house's cut ----
//
// A game that takes a rake or a time charge has chips leaving the table every hand,
// and until now the bank check called them missing: it added up the buy-ins,
// added up the chips on the felt, and told the dealer somebody had been paid for
// chips they never got. The house's cut is the one thing on this table that is
// supposed to go and not come back, so it is counted rather than mourned.
//
// Percentage with a cap, which is what nearly every room and home game runs. Zero
// percent is a game that doesn't rake, which is the default and the common case.
// What the house takes from a pot of this size.
//
// "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
// flop pays nothing, because the players never got a hand out of it. It is on by
// default and can be turned off for a house that rakes every pot.
rakeOn(state, pot, sawFlop = true) {
const rake = state.rake || {};
const percent = Number(rake.percent) || 0;
const amount = Money.round(Number(pot) || 0);
if (percent <= 0 || amount <= 0) return 0;
if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
const uncapped = (amount * percent) / 100;
const cap = Number(rake.cap);
const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
// Never more than the pot, however the numbers were typed in.
return Money.downToChip(Math.min(capped, amount), state.chipLegend);
},
// The same payouts with the house's cut taken out of them.
//
// Off the top of the whole pot, then shared out in proportion — which for the hand
// a dealer actually deals, with one winner, is just "winner gets the pot less the
// rake". Split pots take it proportionally, and whatever the rounding leaves over
// goes back to the winners in turn rather than to the house: the house asked for a
// number, and it gets that number, not that number plus dust.
applyRake(payouts, rake) {
const total = payouts.reduce((sum, p) => sum + p.amount, 0);
const taken = Money.round(Math.min(Math.max(0, rake), total));
if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
const kept = Money.toCents(total) - Money.toCents(taken);
const shares = payouts.map(p => {
const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
return { payout: p, cents: Math.floor(exact) };
});
// Floor everybody, then hand the leftover cents out one at a time, biggest
// share first — the same shape as the odd-chip rule the split calculator uses.
let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
[...shares]
.sort((a, b) => b.payout.amount - a.payout.amount)
.forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
return {
payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
rake: taken,
};
},
// Is anybody actually in this chair? A seat with a name on it, chips in front of
// it, cash against it or a hand dealt to it. Empty chairs are not players, which
// matters the moment the table's starting stack starts standing in for a seat that
// has never been given one — every empty chair would otherwise look like somebody
// holding two hundred dollars.
seatInPlay(player) {
if (!player) return false;
return !!(player.name || "").trim() ||
(Number(player.buyIns) || 0) > 0 ||
(Number(player.stack) || 0) > 0 ||
(Number(player.committed) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
player.inHand === true;
},
// What a seat is holding, the way the felt and the betting see it — which includes
// the table's starting stack standing in for a seat that has never been given one.
//
// This is the number the money side of the tool was missing. "Starting stack" is
// offered in the setup panel as the ordinary way to open a table, and everything a
// dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
// check and the standings read `seat.stack` directly instead, which is null for
// exactly those seats: measured on a three-handed table with a $200 starting stack
// and nobody's own stack set, the felt played for $600 while the bank check said
// "no chips in play, balanced" and the standings listed nobody at all.
chipsHeldBy(state, player) {
if (!this.seatInPlay(player)) return 0;
return Math.max(0, Money.round(Betting.stackOf(state, player)));
},
// Seats playing with chips the box has no cash against.
//
// Two kinds, and they are not the same problem. A seat holding the table's starting
// stack has never been given chips of its own — the dealer set "everyone starts
// with 200" and sat people down, which is the ordinary way to open a table and the
// reason this whole gap existed. A seat with its own stack and no buy-in is a
// rebuy somebody forgot to write down, and only the dealer knows what it was.
seatsOwingCash(state) {
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
!((Number(player.buyIns) || 0) > 0))
.map(({ player, index }) => ({
index,
name: this.displayName(player, index),
chips: this.chipsHeldBy(state, player),
// True when they are holding the table's starting stack rather than one
// the dealer typed for them.
fromStartingStack: player.stack === null || player.stack === undefined ||
String(player.stack).trim() === "",
}));
},
// Write down the cash for the seats that are holding the table's starting stack.
// Only those: what somebody else's unrecorded rebuy was is a question only the
// dealer can answer, and guessing it would be the tool inventing money.
recordStartingBuyIns(state) {
const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
return {
seats: owed.length,
total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
};
},
bankReconciliation(state) {
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
// Cash the box took from people who have since left counts as taken: it is in
// the box, and forgetting it with the seat is what used to make a cash-out read
// as a shortfall all night.
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) +
gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0);
const chipsInPlay = state.seats.reduce(
(sum, p) =>
sum +
Math.max(0, Number(p.stack) || 0) +
this.chipsHeldBy(state, p) +
Math.max(0, Number(p.committed) || 0) +
Math.max(0, Number(p.amountOverride) || 0),
0
@@ -602,11 +1170,21 @@ const HoldemState = {
// hunting for a miscount that never happened.
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
const difference = Money.round(chipsInPlay + discarded - cashIn);
// Chips the house has taken. Off the table for good, like the discards, and
// for a better reason: the box is supposed to have them.
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
// Chips handed back over the rail. Off the table like the other two, and the
// one the dealer can point at when somebody asks where the money went.
const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0));
const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
raked,
paidOut,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
+16 -1
View File
@@ -36,7 +36,22 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen
no way to scroll to it, that no grid column has collapsed to nothing, and that no
text is painted outside its box. It runs over the table, all five overlays and Quick
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
phones on their side.
phones on their side — each viewport twice, once with a mouse and once with a thumb.
That last part, and two of those checks, are newer than this paragraph claiming them.
`runAll()` did not include the contrast check at all, and touch targets sat behind an
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
eleven for a long run of rounds. Both were written and tested and simply never called.
The mouse/thumb pair matters for the same reason: the 44px rule stands itself down
where `hover: hover` says there is a pointer, and a headless desktop browser is a
pointer, so a single pass had never measured a touch target at any viewport. Between
them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete
button on Quick Split.
A check that decides it doesn't apply says so in the output rather than reporting no
offenders, which reads identically to a pass. The same goes for the two exemptions
that exist: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
this one needs a browser and a running site. Run it after any change that adds or
+108
View File
@@ -0,0 +1,108 @@
// The sums the contrast check runs on, tested away from a browser.
//
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
// and the arithmetic is the part that decides whether a real fault is reported or
// waved through. Faded text used to be filed as "not checked" rather than measured;
// this is the compositing that replaced that.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const LayoutAudit = require("./uiAudit");
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
describe("the ratio itself", () => {
test("black on white is the full 21:1, either way round", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
});
test("a colour against itself is 1:1", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
});
});
describe("text on a box that is being faded", () => {
// opacity on a box fades the box and its text together onto whatever is behind
// it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
// faded text both landing on the page.
const fade = alpha => ({ alpha, behind: PAGE });
test("at full strength nothing is composited", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
test("fading moves both the text and its background, not just the text", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
assert.ok(painted.fg.r < WHITE.r, "the text dims");
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
});
test("which is what stops a fade being reported as a fault it isn't", () => {
// Dimming light text on a dark puck over a dark page costs contrast, but far
// less than measuring the faded text against the *unfaded* puck would claim.
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
});
test("a fade all the way out lands everything on the page behind", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
assert.deepEqual(painted.fg, PAGE);
assert.deepEqual(painted.bg, PAGE);
});
test("with nothing known to be behind it, the fade is left alone", () => {
// Better to measure the unfaded pair than to invent a backdrop.
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
});
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
// Deliberately not asserting a particular CSS colour here: the page's own greys are
// measured in the browser, against the real gradients, where they live. What this
// pins is the direction — that dimming a box costs its text contrast, which is why
// filing faded text as "not checked" hid faults rather than avoiding false ones.
test("the same grey scores lower once its box is dimmed", () => {
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
LayoutAudit.contrastRatio(solid.fg, solid.bg));
});
test("and a lighter grey scores higher at the same fade", () => {
const at = colour => {
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
};
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
});
});
describe("reading a colour off the page", () => {
test("rgb and rgba both come back as numbers", () => {
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
});
test("a gradient gives up every stop, because text could be over any of them", () => {
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
assert.equal(stops.length, 2);
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
});
test("no background image, no stops", () => {
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
});
});
+55
View File
@@ -167,3 +167,58 @@ describe("does the cash box cover the chips on the table", () => {
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
describe("the number the banker counts out", () => {
// The standings had two signed columns and neither was an amount to pay. A player
// up $140 on a $340 stack read "$140" under a heading saying "Cash out", at the
// exact moment somebody was counting money onto the table.
test("chips in front is what they are holding, not what they are up", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 340 });
state.seats[0].name = "Ann";
state.seats[0].buyIns = 200;
state.seats[1].buyIns = 200;
state.seats[1].stack = 60;
const rows = HoldemState.sessionStandings(state);
const ann = rows.find(r => r.name === "Ann");
assert.equal(ann.chipsInFront, 340, "the cash to count out");
assert.equal(ann.cashPosition, 140, "and separately, that they are up $140");
});
test("a player who is down still has chips to be handed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 60 });
state.seats[0].name = "Cal";
state.seats.forEach(p => { p.buyIns = 200; });
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Cal");
assert.equal(cal.chipsInFront, 60, "they leave with $60 of chips");
assert.equal(cal.cashPosition, -140, "having lost $140 of the $200 they brought");
});
test("chips still out in the middle are counted as theirs", () => {
// Cashing out mid-hand isn't a thing, but the panel is read mid-hand constantly
// and a stack that drops by the size of a bet looks like money going missing.
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
state.seats[0].name = "Dee";
state.seats.forEach(p => { p.buyIns = 200; });
Betting.beginHand(state);
Betting.raiseTo(state, 0, 50);
const dee = HoldemState.sessionStandings(state).find(r => r.name === "Dee");
assert.equal(dee.chipsInFront, 200, "stack plus what's in the middle for them");
assert.equal(dee.cashPosition, 0, "so they are still level");
});
test("chips and up/down always differ by exactly the buy-in", () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 100 });
[340, 150, 60, 250].forEach((chips, i) => {
state.seats[i].stack = chips;
state.seats[i].buyIns = 200 + i * 50;
});
HoldemState.sessionStandings(state).forEach(row => {
assert.equal(Money.round(row.chipsInFront - row.boughtIn), row.cashPosition,
`${row.name}'s two columns should tell one story`);
});
});
});
+129
View File
@@ -0,0 +1,129 @@
// Calling the clock on a player.
//
// The bar already had a stopwatch saying how long somebody had been thinking. This is
// what a dealer does about it: a procedure with a shape — a set time, a countdown said
// out loud, and a ruling at the end that depends on what the player was facing.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar, Betting, makeTable } = require("./load");
// The bar rebuilds the DOM when a clock starts; none of the rules below need it.
const onTable = (state) => {
ActionBar.state = state;
ActionBar.render = () => {};
ActionBar.stopClock();
return ActionBar;
};
const dealt = () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
Betting.beginHand(state);
return state;
};
describe("starting one", () => {
test("it runs on the player who is actually on the clock", () => {
const state = dealt();
const bar = onTable(state);
assert.equal(bar.clockRunning(), false, "nothing runs until it is called");
bar.callClock();
assert.equal(bar.clockRunning(), true);
assert.equal(bar.clockSeatIndex, state.actingSeatIndex);
});
test("with nobody to act there is nothing to call", () => {
const state = dealt();
state.actingSeatIndex = null;
const bar = onTable(state);
bar.callClock();
assert.equal(bar.clockRunning(), false);
assert.equal(bar.clockLeft(), null);
});
test("the dealer can put it away again", () => {
const bar = onTable(dealt());
bar.callClock();
bar.stopClock();
assert.equal(bar.clockRunning(), false);
assert.equal(bar.clockLeft(), null);
});
});
describe("the count", () => {
const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000);
test("is the player's own time and then a countdown", () => {
const bar = onTable(dealt());
bar.callClock();
assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S);
assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts");
assert.equal(at(bar, 35), 5);
});
test("stops at zero rather than going negative", () => {
const bar = onTable(dealt());
bar.callClock();
assert.equal(at(bar, 40), 0);
assert.equal(at(bar, 600), 0, "ten minutes later it is still just up");
});
});
describe("what happens at zero", () => {
test("facing a bet, the hand is dead", () => {
const state = dealt();
const bar = onTable(state);
// Whoever is up pre-flop is facing the big blind.
const ruling = bar.clockRuling();
assert.equal(ruling.verb, "fold");
assert.match(ruling.label, /hand is dead/);
assert.match(ruling.say, /facing \$2/);
});
test("facing nothing, it is checked — slowness doesn't fold a free hand", () => {
const state = dealt();
// Everyone calls round to the big blind, who has nothing to call.
[3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat));
const bar = onTable(state);
assert.equal(state.actingSeatIndex, 2, "the big blind is up");
const ruling = bar.clockRuling();
assert.equal(ruling.verb, "check");
assert.match(ruling.label, /check for/i);
});
test("the ruling names the player, because the dealer has to say it out loud", () => {
const state = dealt();
state.seats[state.actingSeatIndex].name = "Priya";
const ruling = onTable(state).clockRuling();
assert.match(ruling.label, /Priya/);
assert.match(ruling.say, /Priya/);
});
test("with nobody to act there is no ruling to make", () => {
const state = dealt();
state.actingSeatIndex = null;
assert.equal(onTable(state).clockRuling(), null);
});
});
describe("what ends it", () => {
test("the player acting", () => {
const state = dealt();
const bar = onTable(state);
bar.callClock();
const wasOn = state.actingSeatIndex;
Betting.call(state, wasOn);
assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved");
assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's");
});
test("and it doesn't follow the action to the next player", () => {
// A clock is called on one decision. Left running it would put a countdown on
// somebody nobody asked about.
const state = dealt();
const bar = onTable(state);
bar.callClock();
Betting.call(state, state.actingSeatIndex);
assert.equal(bar.clockLeft(), null);
});
});
+229
View File
@@ -0,0 +1,229 @@
// Somebody leaving with their chips.
//
// The most ordinary thing that happens at a home game after midnight, and the tool had
// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
// check — cash taken against chips on the table — declared the departing player's whole
// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
// price of losing their night from the standings and the file the dealer hands over.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, Betting, makeTable } = require("./load");
const table = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].buyIns = 200;
});
return state;
};
describe("counting them out", () => {
test("the preview is what the dealer's hands follow", () => {
const state = table();
state.seats[0].stack = 350;
const preview = HoldemState.cashOutPreview(state, 0);
assert.equal(preview.name, "Ann");
assert.equal(preview.chips, 350, "what they are holding");
assert.equal(preview.boughtIn, 200);
assert.equal(preview.net, 150, "up for the night");
assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
});
test("a player who is down reads as down, not as a smaller stack", () => {
const state = table();
state.seats[1].stack = 60;
assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
});
test("a seat that never set a stack is holding the table's starting stack", () => {
const state = table();
state.defaultStack = 200;
state.seats[2].stack = null;
assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
});
});
describe("what it refuses", () => {
test("paying somebody with money still in the middle", () => {
const state = table();
Betting.beginHand(state);
const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
const refusal = HoldemState.canCashOut(state, bb);
assert.equal(refusal.ok, false);
assert.match(refusal.reason, /in the pot/);
// And the refusal is the whole story: nothing moved.
assert.equal(HoldemState.cashOut(state, bb).ok, false);
assert.equal((state.cashedOut || []).length, 0);
assert.ok(state.seats[bb].name, "the seat is untouched");
});
test("an empty chair, which has nobody to pay", () => {
const state = table();
const refusal = HoldemState.canCashOut(state, 99);
assert.equal(refusal.ok, false);
});
});
describe("the chair afterwards", () => {
test("is empty and ready for the next player", () => {
const state = table();
state.seats[0].stack = 350;
HoldemState.cashOut(state, 0);
const seat = state.seats[0];
assert.equal(seat.name, "");
assert.equal(seat.stack, null);
assert.equal(seat.buyIns, 0);
assert.equal(seat.inHand, false);
});
test("belongs to somebody new, so two people's nights can't merge", () => {
const state = table();
const wasId = state.seats[0].id;
HoldemState.cashOut(state, 0);
assert.notEqual(state.seats[0].id, wasId);
});
test("and isn't owed the blind the person before them was posting", () => {
const state = table();
state.postingIn = [state.seats[0].id, state.seats[1].id];
HoldemState.cashOut(state, 0);
assert.deepEqual(state.postingIn, [state.seats[1].id]);
});
});
describe("the bank check", () => {
test("balanced before, balanced after — the chips went somewhere known", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50;
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
HoldemState.cashOut(state, 0);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.paidOut, 350);
assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
assert.equal(bank.chipsInPlay, 450);
assert.equal(bank.balanced, true);
});
test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50; // so the table starts out balanced
// What a dealer had to do before: empty the chair themselves.
state.seats[0].name = "";
state.seats[0].stack = null;
state.seats[0].inHand = false;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -350, "the tool called it missing money");
});
test("chips that really are missing still show as missing", () => {
// Counting a cash-out must not become a way of swallowing a genuine shortfall.
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50; // balanced to start with
HoldemState.cashOut(state, 0);
state.seats[2].stack = 150; // and now fifty chips have gone astray
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -50);
});
test("a rake and a cash-out are counted apart", () => {
const state = table();
state.seats[0].stack = 350;
state.rakeTaken = 12;
state.seats[1].stack = 38; // the rake came out of Ben's stack
HoldemState.cashOut(state, 0);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.raked, 12);
assert.equal(bank.paidOut, 350);
assert.equal(bank.balanced, true);
});
});
describe("the record of their night", () => {
test("they stay in the standings with what they bought and what they left with", () => {
const state = table();
state.seats[0].stack = 350;
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
HoldemState.cashOut(state, 0);
const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
assert.ok(ann, "still on the list");
assert.equal(ann.boughtIn, 200);
assert.equal(ann.chipsInFront, 350, "what the box handed over");
assert.equal(ann.cashPosition, 150);
assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
});
test("somebody still sitting there is not marked", () => {
const state = table();
HoldemState.cashOut(state, 0);
const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
assert.equal(ben.cashedOut, false);
});
test("two people leaving are both kept", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50;
HoldemState.cashOut(state, 0);
HoldemState.cashOut(state, 1);
assert.equal(state.cashedOut.length, 2);
assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("a table saved before any of this existed has nobody cashed out", () => {
const parsed = JSON.parse(JSON.stringify(table()));
delete parsed.cashedOut;
const migrated = HoldemState.migrate(parsed);
assert.deepEqual(migrated.cashedOut, []);
assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
});
test("nonsense in a saved record is cleaned up rather than trusted", () => {
const parsed = JSON.parse(JSON.stringify(table()));
parsed.cashedOut = [
{ id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
{ name: "no id" },
null,
];
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.cashedOut.length, 1);
assert.equal(migrated.cashedOut[0].buyIns, 0);
assert.equal(migrated.cashedOut[0].chips, 350);
assert.equal(migrated.cashedOut[0].at, 0);
});
});
describe("the table carries on", () => {
test("the next hand deals round the empty chair", () => {
const state = table();
state.seats[0].stack = 350;
HoldemState.cashOut(state, 0);
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
assert.equal(Betting.liveSeats(state).length, 3);
assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
});
test("and the money still adds up once it has", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50;
HoldemState.cashOut(state, 0);
HoldemState.startNextHand(state);
Betting.beginHand(state);
const bank = HoldemState.bankReconciliation(state);
assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
assert.equal(bank.balanced, true);
});
});
+268
View File
@@ -0,0 +1,268 @@
// The button when somebody gets up out of the blinds.
//
// Passing the button to the next *player* is right until a chair between the button
// and the blinds empties. Then it pulls the whole blind order forward a place: the
// player after the empty chair is never asked for a big blind, and the one after them
// is asked an orbit early. The dead button rule holds the rotation to the seats for
// one hand so the big blind still advances exactly one player.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, makeTable } = require("./load");
// Four players in six chairs: Ann 0, Ben 1, Cal 2, Dee 3.
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
const table = (dealerSeatIndex = 0) => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
NAMES.forEach((name, i) => { state.seats[i].name = name; });
return state;
};
const nameOf = (state, index) =>
index === null || index === undefined ? null : (state.seats[index].name || `empty seat ${index + 1}`);
const blinds = state => {
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
return { sb: nameOf(state, sbIndex), bb: nameOf(state, bbIndex) };
};
const leave = (state, index) => {
state.seats[index].name = "";
state.seats[index].inHand = false;
state.seats[index].stack = 0;
state.seats[index].buyIns = 0;
};
describe("somebody gets up out of the blinds", () => {
test("the small blind leaves: dead button, and the big blind still moves one player", () => {
const state = table(1); // button Ben, small Cal, big Dee
Betting.beginHand(state);
assert.deepEqual(blinds(state), { sb: "Cal", bb: "Dee" });
leave(state, 2); // Cal takes their chips and goes
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.dealerSeatIndex, 2, "the button holds Cal's empty chair");
assert.equal(state.seats[2].inHand, false, "which is the point — it's on nobody");
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ann" },
"Dee moves from big to small and Ann posts the big blind, one player along");
});
test("and Ann is not skipped, which is the money in it", () => {
// The old rotation passed the button to the next player, which made the blinds
// Ann/Ben — Ann never posting a big blind at all, and Ben posting one an orbit
// early. That is a real big blind out of a real player's stack.
const state = table(1);
Betting.beginHand(state);
leave(state, 2);
HoldemState.startNextHand(state);
assert.equal(blinds(state).bb, "Ann", "Ann owes the big blind and still owes it");
});
test("the big blind leaves: no small blind is posted this hand", () => {
const state = table(1); // button Ben, small Cal, big Dee
Betting.beginHand(state);
leave(state, 3); // Dee goes
HoldemState.startNextHand(state);
Betting.beginHand(state);
const { sbIndex, bbIndex, deadSmallBlind } = HoldemState.getBlindSeats(state);
assert.equal(state.dealerSeatIndex, 2, "the button reaches Cal as it was always going to");
assert.equal(sbIndex, null, "the small blind is dead");
assert.equal(deadSmallBlind, true, "and says so, rather than looking like a table with no blinds");
assert.equal(nameOf(state, bbIndex), "Ann", "the big blind still moves on one player");
});
test("a dead small blind still deals a hand, with one blind in the middle", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 3);
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.bettingOpen, true, "the hand goes ahead");
const posted = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
assert.equal(posted, state.bigBlind, "just the big blind, and nothing conjured to stand in for the small");
assert.equal(state.currentBet, state.bigBlind);
assert.ok(state.actingSeatIndex !== null, "and somebody is on the clock");
});
test("somebody leaving from anywhere else changes nothing", () => {
const state = table(1); // button Ben, small Cal, big Dee
Betting.beginHand(state);
leave(state, 0); // Ann, who was under the gun
HoldemState.startNextHand(state);
assert.equal(state.dealerSeatIndex, 2, "the button passes to Cal exactly as before");
assert.equal(state.deadButton, false);
assert.equal(state.deadSmallBlind, false);
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ben" });
});
});
describe("the ordinary rotation is untouched", () => {
test("a full table passes the button one player at a time", () => {
const state = table(0);
const seen = [];
for (let hand = 0; hand < 8; hand++) {
Betting.beginHand(state);
seen.push(blinds(state).bb);
HoldemState.startNextHand(state);
}
assert.deepEqual(seen, ["Cal", "Dee", "Ann", "Ben", "Cal", "Dee", "Ann", "Ben"],
"two clean orbits, everybody paying one big blind per orbit");
assert.equal(state.deadButton, false);
assert.equal(state.deadSmallBlind, false);
});
test("players spread thinly around a big table don't create dead positions", () => {
// Three players in nine chairs. The chairs between them were never in the
// rotation, so holding to seats there would post blinds to nobody hand after
// hand — which is why the button follows players, not chairs, by default.
const state = makeTable({ seatCount: 9, seated: [0, 4, 7], stack: 200, dealerSeatIndex: 0 });
["Ann", "", "", "", "Ben", "", "", "Cal"].forEach((n, i) => { state.seats[i].name = n; });
const seen = [];
for (let hand = 0; hand < 6; hand++) {
Betting.beginHand(state);
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.ok(state.seats[sbIndex].inHand, "a real player posts the small blind");
assert.ok(state.seats[bbIndex].inHand, "and a real player the big");
seen.push(nameOf(state, bbIndex));
HoldemState.startNextHand(state);
}
assert.deepEqual(seen, ["Cal", "Ann", "Ben", "Cal", "Ann", "Ben"], "one big blind each per orbit");
});
test("pressing next hand twice without dealing just passes the button along", () => {
const state = table(0);
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.equal(state.dealerSeatIndex, 1);
HoldemState.startNextHand(state);
assert.equal(state.dealerSeatIndex, 2, "and doesn't rotate off the same hand twice");
});
});
describe("the rule knows where it stops", () => {
test("down to two players, heads-up takes over and nothing is left dead", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 2);
leave(state, 3); // both blinds walk at once
HoldemState.startNextHand(state);
Betting.beginHand(state);
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, true);
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
assert.ok(state.seats[state.dealerSeatIndex].inHand, "and the button is on a player again");
});
test("a button stranded by accident is still rescued", () => {
// Not the dead button: nobody left out of the blinds, the chair the button is
// on just emptied. That one does want putting back on a player.
const state = table(3);
state.seats[3].inHand = false;
HoldemState.ensureButtonOnPlayer(state);
assert.ok(state.seats[state.dealerSeatIndex].inHand);
});
test("moving the button by hand clears the dead positions", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 2);
HoldemState.startNextHand(state);
assert.equal(state.deadButton, true);
// What the seat menu's "Give the button" does.
state.dealerSeatIndex = 0;
state.deadButton = false;
state.deadSmallBlind = false;
Betting.beginHand(state);
const { sbIndex } = HoldemState.getBlindSeats(state);
assert.ok(state.seats[sbIndex].inHand, "a real small blind again, where the dealer put it");
});
test("naming a blind by hand overrules a dead one", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 3);
HoldemState.startNextHand(state);
assert.equal(state.deadSmallBlind, true);
state.blindOverride = { sbIndex: 1, bbIndex: 0 };
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
assert.equal(manual, true);
assert.equal(sbIndex, 1, "the dealer's word beats the rule");
assert.equal(bbIndex, 0);
});
test("a table saved before any of this reads as nothing dead", () => {
const state = table(0);
const parsed = JSON.parse(JSON.stringify(state));
delete parsed.deadButton;
delete parsed.deadSmallBlind;
delete parsed.postedBlinds;
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
globalThis.localStorage = {
getItem: key => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, value),
removeItem: key => store.delete(key),
};
const loaded = HoldemState.load();
assert.equal(loaded.deadButton, false);
assert.equal(loaded.deadSmallBlind, false);
assert.equal(loaded.postedBlinds, null);
HoldemState.startNextHand(loaded);
assert.ok(loaded.seats[loaded.dealerSeatIndex].inHand, "and rotates the ordinary way");
delete globalThis.localStorage;
});
});
describe("chips are not invented or lost by any of it", () => {
test("posting one blind instead of two moves chips and doesn't make them", () => {
// Measured across the deal, which is the step that touches stacks. A hand with
// a dead small blind has one blind in the middle instead of two — the danger is
// a null small blind being treated as a zero-cost posting somewhere, or the big
// blind quietly covering for it.
const state = table(1);
const chips = s => s.seats.reduce(
(sum, p) => sum + Betting.stackOf(s, p) + Betting.handTotal(p), 0);
Betting.beginHand(state);
leave(state, 3); // the big blind goes, small blind dies
for (let hand = 0; hand < 4; hand++) {
HoldemState.startNextHand(state);
const before = chips(state);
Betting.beginHand(state);
assert.equal(chips(state), before, `the deal drifted the count on hand ${hand + 1}`);
}
});
test("a dead small blind takes exactly one blind out of one stack", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 3);
HoldemState.startNextHand(state);
const stacks = () => state.seats.map(p => Betting.stackOf(state, p));
const before = stacks();
Betting.beginHand(state);
const after = stacks();
const paid = before.map((v, i) => v - after[i]).filter(d => d !== 0);
assert.deepEqual(paid, [state.bigBlind], "one player, one big blind, nobody else touched");
});
});
+73
View File
@@ -0,0 +1,73 @@
// How tall the felt is allowed to be.
//
// The bar is fixed to the bottom of the screen and grows with the hand, so the table
// has to be told what is left. Everything below is the arithmetic that decides it —
// the part that can be wrong without anyone noticing until a seat is behind the Fold
// button.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar } = require("./load");
// window height, bar height, and how far down the page the felt starts.
const room = (windowHeight, barHeight, top) => ActionBar.feltRoomFor(windowHeight, barHeight, top);
describe("the room left for the felt", () => {
test("is what the window has after the bar, the header and the bottom seat", () => {
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
assert.equal(room(900, 127, 146), 900 - 127 - 146 - reserved);
});
test("shrinks when the bar grows mid-hand", () => {
// Side pots make the bar taller exactly when the dealer most needs to see who
// is still in.
const quiet = room(900, 117, 146);
const sidePots = room(900, 172, 146);
assert.equal(quiet - sidePots, 55);
});
test("leaves the bottom seat's overhang out of it", () => {
// The seat pucks hang below the felt's edge; the felt has to stop short by that
// much or the ring ends up in the bar even though the felt doesn't.
assert.ok(ActionBar.SEAT_OVERHANG_PX > 30, "half a puck");
assert.ok(room(900, 127, 146) < 900 - 127 - 146);
});
});
describe("a window too short to give what was asked", () => {
// A phone on its side: 390px tall, 127px of that is the bar and 119px is
// everything above the felt. What's left is under 100.
const landscape = () => room(390, 127, 119);
test("gets the measurement, not a comfortable number", () => {
// The floor used to be 220px, which this window hasn't got. Holding out for it
// put the bottom row of seats 26px inside the action bar — the exact fault the
// measurement exists to prevent.
assert.ok(landscape() < 150, `${landscape()}px`);
assert.ok(landscape() + 119 + 127 <= 390, "the felt still ends above the bar");
});
test("still draws a table rather than a line", () => {
assert.equal(ActionBar.feltRoomFor(200, 160, 100), ActionBar.FELT_FLOOR_PX);
assert.equal(ActionBar.feltRoomFor(0, 0, 0), ActionBar.FELT_FLOOR_PX);
});
test("never returns something a browser can't lay out", () => {
[[390, 300, 200], [100, 90, 90], [-10, 0, 0]].forEach(([h, bar, top]) => {
const answer = ActionBar.feltRoomFor(h, bar, top);
assert.ok(Number.isFinite(answer) && answer > 0, `${h}/${bar}/${top} gave ${answer}`);
});
});
});
describe("a window with room to spare", () => {
test("hands over everything that's left, floor or no floor", () => {
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
assert.equal(room(1000, 127, 146), 1000 - 127 - 146 - reserved);
assert.equal(room(1366, 120, 206), 1366 - 120 - 206 - reserved);
});
test("gives a whole number of pixels", () => {
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
});
});
+123
View File
@@ -0,0 +1,123 @@
// Finding one hand in a night of them.
//
// What the log is actually opened for is a single hand — the one somebody at the rail
// is arguing about. A night runs to sixty or more, five at a time in the panel, and
// there was no way to ask for one: the dealer scrolled and read every row.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
const hand = (handNumber, pot, payouts, ledger = []) => ({
handNumber, pot, timestamp: Date.now(), payouts, ledger,
});
// A night as the tool records it: labels carry the cards the wizard was shown.
const NIGHT = [
hand(1, 40, [{ label: "Ann (A♠ K♠)", amount: 40 }], [{ name: "Ann", in: 20 }, { name: "Ben", in: 20 }]),
hand(2, 190, [{ label: "Fay", amount: 190 }], [{ name: "Fay", in: 95 }, { name: "Gus", in: 95 }]),
hand(12, 19, [{ label: "Ben", amount: 19 }], [{ name: "Ben", in: 10 }, { name: "Ann", in: 9 }]),
hand(120, 76, [{ label: "Cal", amount: 38 }, { label: "Dee", amount: 38 }], [{ name: "Cal" }, { name: "Dee" }]),
];
const found = query => NIGHT.filter(h => Render.handMatchesFilter(h, query)).map(h => h.handNumber);
describe("no filter", () => {
test("shows the whole night", () => {
assert.deepEqual(found(""), [1, 2, 12, 120]);
assert.deepEqual(found(" "), [1, 2, 12, 120]);
assert.deepEqual(found(null), [1, 2, 12, 120]);
});
});
describe("by name", () => {
test("finds the hand somebody won", () => {
assert.deepEqual(found("Fay"), [2]);
});
test("case doesn't matter — nobody types a capital at 2am", () => {
assert.deepEqual(found("fay"), [2]);
assert.deepEqual(found("FAY"), [2]);
});
test("a name inside a label with cards on it still matches", () => {
// The wizard writes the label as "Ann (A♠ K♠)", so the name is a substring
// rather than the whole cell.
assert.deepEqual(found("ann"), [1]);
});
test("matches what the row shows, not everyone who was in the hand", () => {
// Gus is in hand 2's ledger — he paid for it — but the row names the winner,
// and searching text the panel doesn't display is a filter that looks broken
// even when it's right. Measured on a seven-handed night where nobody folded,
// matching the ledger returned 60 of 60 for every name at the table.
assert.deepEqual(found("gus"), []);
});
test("a split pot is found by either winner", () => {
assert.deepEqual(found("cal"), [120]);
assert.deepEqual(found("dee"), [120]);
});
test("a name nobody has finds nothing rather than everything", () => {
assert.deepEqual(found("zed"), []);
});
});
describe("by amount", () => {
test("finds a pot", () => {
assert.deepEqual(found("190"), [2]);
});
test("a dollar sign and commas are ignored", () => {
assert.deepEqual(found("$190"), [2]);
assert.deepEqual(found("$1,90"), [2], "typed badly, still found");
});
test("finds a payout that isn't the whole pot", () => {
assert.deepEqual(found("38"), [120], "half of a chopped $76");
});
test("a partial amount narrows rather than missing", () => {
// Half remembering "something around forty" should still get there.
assert.deepEqual(found("4"), [1], "$40");
assert.deepEqual(found("19"), [2, 12], "the $190 pot and the $19 one");
});
test("a bare number is an exact hand number, not a prefix of one", () => {
// Amounts match on their leading digits, because half a remembered figure is
// still a lead. Hand numbers don't: typing 12 to mean the hand is a dealer who
// knows which hand they want, and dragging 120 and 128 along with it is noise.
// "#12" is there for when they do want the prefix.
assert.ok(!found("12").includes(120));
});
});
describe("by hand number", () => {
test("a bare number matches the hand number as well as amounts", () => {
assert.ok(found("12").includes(12));
});
test("a hash pins it to the hand number and nothing else", () => {
assert.deepEqual(found("#12"), [12, 120], "12 and 120 both start with 12");
assert.deepEqual(found("#2"), [2], "and not the $190 pot of hand 2's neighbours");
});
test("a hash with a number nobody played finds nothing", () => {
assert.deepEqual(found("#7"), []);
});
});
describe("odd input can't break it", () => {
test("punctuation on its own doesn't match everything", () => {
assert.deepEqual(found("$"), [], "no digits and no letters left to match on");
assert.deepEqual(found("#"), [1, 2, 12, 120], "an empty hand number is a prefix of them all");
});
test("an entry with pieces missing doesn't throw", () => {
const broken = { handNumber: 5, pot: 10 };
assert.doesNotThrow(() => Render.handMatchesFilter(broken, "ann"));
assert.equal(Render.handMatchesFilter(broken, "ann"), false);
assert.equal(Render.handMatchesFilter(broken, "10"), true, "the pot is still findable");
});
});
+5
View File
@@ -29,6 +29,10 @@ globalThis.Render = Render;
const HoldemState = require(path.join(SRC, "state.js"));
globalThis.HoldemState = HoldemState;
// actionBar.js after state, since its own module-level code reaches for neither but
// its methods reach for both. Loaded last so nothing it touches is still undefined.
const ActionBar = load("actionBar");
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -81,6 +85,7 @@ module.exports = {
SettleUp,
Betting,
HoldemState,
ActionBar,
makeTable,
chipsOnTable,
contributions,
+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);
});
});
+238
View File
@@ -0,0 +1,238 @@
// Somebody who walks up and sits down after the blinds have gone past them.
//
// Dealing them straight in is a free hand in late position — which is what posting
// exists to stop, and it comes out of everybody else's night a couple of dollars at a
// time. The tool used to do it silently. It now says so, and takes the post if the
// dealer says to.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, makeTable } = require("./load");
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
const table = (dealerSeatIndex = 0) => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
NAMES.forEach((name, i) => { state.seats[i].name = name; });
return state;
};
// Eve walks up and takes seat 5 while the table is running.
const seatEve = state => {
state.seats[4].name = "Eve";
state.seats[4].inHand = true;
state.seats[4].stack = 200;
state.seats[4].buyIns = 200;
return 4;
};
const debtors = state => {
const { bbIndex } = HoldemState.getBlindSeats(state);
return HoldemState.blindDebtors(state, bbIndex).map(i => state.seats[i].name);
};
describe("who is owed a blind", () => {
test("nobody, at a table that hasn't dealt a hand yet", () => {
// Everyone is new on the first hand. Asking the whole table to post would be
// the tool mistaking the start of the night for five people walking up late.
const state = table();
assert.deepEqual(debtors(state), []);
});
test("nobody, on an ordinary hand where the same people are still sitting there", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.deepEqual(debtors(state), []);
});
test("the player who has just sat down", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
seatEve(state);
assert.deepEqual(debtors(state), ["Eve"]);
});
test("not once they've been dealt a hand", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
seatEve(state);
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.deepEqual(debtors(state), [], "she's one of the regulars now");
});
test("not somebody who is the big blind this hand — they're paying already", () => {
const state = table(0); // blinds Ben and Cal
Betting.beginHand(state);
HoldemState.startNextHand(state); // blinds move to Cal and Dee
// Dee sits out for a hand and comes straight back into her own big blind.
state.seats[3].inHand = false;
Betting.beginHand(state);
HoldemState.startNextHand(state);
state.seats[3].inHand = true;
const { bbIndex } = HoldemState.getBlindSeats(state);
assert.ok(!HoldemState.blindDebtors(state, bbIndex).includes(bbIndex),
"the big blind is never asked to post on top of their blind");
});
test("somebody back from sitting out is owed one just like a newcomer", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
state.seats[3].inHand = false; // Dee stands up for a hand
Betting.beginHand(state);
HoldemState.startNextHand(state);
state.seats[3].inHand = true; // and sits back down
assert.ok(debtors(state).includes("Dee"), "the blinds went past her while she was up");
});
test("a seat sat out for having no chips isn't owed anything", () => {
// They aren't being dealt in, so there's no free hand to charge for.
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.seats[eve].stack = 0;
const preview = Betting.blindPreview(state);
assert.ok(!preview.debtors.includes(eve), "she's sitting out until she buys chips");
});
});
describe("posting to come in", () => {
test("the post goes in the middle and buys a hand", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.postingIn = [state.seats[eve].id];
const before = Betting.stackOf(state, state.seats[eve]);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "she's posted a big blind");
assert.equal(Betting.stackOf(state, state.seats[eve]), before - state.bigBlind,
"out of her own stack, not conjured");
assert.equal(state.currentBet, state.bigBlind, "and it doesn't raise the hand");
});
test("not posting leaves her stack alone", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
const before = Betting.stackOf(state, state.seats[eve]);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[eve]), 0);
assert.equal(Betting.stackOf(state, state.seats[eve]), before);
});
test("the pot is what the blinds and the post add up to", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.postingIn = [state.seats[eve].id];
Betting.beginHand(state);
const middle = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
assert.equal(middle, state.smallBlind + state.bigBlind + state.bigBlind);
});
test("it's spent after the hand, not a standing charge", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.postingIn = [state.seats[eve].id];
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.deepEqual(state.postingIn, [], "or she'd pay an extra blind every hand all night");
Betting.beginHand(state);
assert.ok(Betting.committed(state.seats[eve]) === 0 ||
state.seats[eve].id === state.seats[HoldemState.getBlindSeats(state).bbIndex]?.id,
"she pays nothing extra on the next hand");
});
test("moving the button after a post doesn't lose it", () => {
// repostBlinds takes every posted chip back and deals again. A post that
// cleared itself on the way in would be refunded and never retaken.
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.postingIn = [state.seats[eve].id];
Betting.beginHand(state);
const chips = () => state.seats.reduce(
(sum, p) => sum + Betting.stackOf(state, p) + Betting.handTotal(p), 0);
const before = chips();
state.dealerSeatIndex = 2;
Betting.repostBlinds(state);
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "still posted");
assert.equal(chips(), before, "and no chips made or lost in the shuffle");
});
test("a post from somebody who isn't owed one is not taken", () => {
// Only seats actually being dealt in can post, and only for their own chips.
const state = table();
state.postingIn = [state.seats[3].id];
state.seats[3].inHand = false;
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[3]), 0, "she isn't even in the hand");
});
});
describe("what the dealer is shown", () => {
test("the prompt has something to say about it, and something to press", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
const preview = Betting.blindPreview(state);
assert.deepEqual(preview.debtors, [eve]);
assert.deepEqual(preview.posting, [], "not posting until the dealer says so");
state.postingIn = [state.seats[eve].id];
assert.deepEqual(Betting.blindPreview(state).posting, [state.seats[eve].id]);
});
test("an old save reads as a table nobody has just walked up to", () => {
const state = table();
Betting.beginHand(state);
const parsed = JSON.parse(JSON.stringify(state));
delete parsed.dealtLastHand;
delete parsed.postingIn;
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
globalThis.localStorage = {
getItem: key => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, value),
removeItem: key => store.delete(key),
};
const loaded = HoldemState.load();
assert.deepEqual(loaded.dealtLastHand, []);
assert.deepEqual(loaded.postingIn, []);
assert.deepEqual(debtors(loaded), [],
"reopening the tab doesn't ask the whole table to post");
delete globalThis.localStorage;
});
});
+180
View File
@@ -0,0 +1,180 @@
// What a pot-sized raise actually is.
//
// The raise pad worked it out as "the current bet plus the pot as it stands", which
// leaves out the fact that the raiser calls first and their call is in the pot before
// the raise is measured off it. Every quick amount came out short — pre-flop at 1/2 it
// offered "Pot $5" where a pot raise is $7. Players say "pot" out loud meaning a
// definite number, and a dealer tapping the button that claims to be it should record
// what was actually said.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, Money, makeTable } = require("./load");
describe("pre-flop at 1/2", () => {
const dealt = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
Betting.beginHand(state);
return state;
};
test("a pot raise from the first player to act is $7", () => {
// Call the $2, which makes the pot $5, then raise $5.
const state = dealt();
assert.equal(Betting.potSoFar(state), 3, "the blinds");
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7);
});
test("half pot is $5 — $4.50 rounded up to something the chips can make", () => {
// The old arithmetic gave $3.50, which clamped up to the minimum and put the
// same $4 on two buttons side by side. The honest half-pot is $4.50, and the
// smallest chip in the default legend is a dollar, so the button would have
// offered an amount the dealer had to round themselves every single time.
const state = dealt();
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5),
Betting.minRaiseTo(state), "the two quick buttons say different things");
});
test("the small blind owes less, so their pot raise is smaller", () => {
// They have $1 in already: call $1, pot becomes $4, raise $4, total $6.
const state = dealt();
const { sbIndex } = require("./load").HoldemState.getBlindSeats(state);
assert.equal(Betting.toCall(state, state.seats[sbIndex]), 1);
assert.equal(Betting.potRaiseTo(state, sbIndex), 6);
});
test("the big blind owes nothing, so it is just the pot", () => {
const state = dealt();
const { bbIndex } = require("./load").HoldemState.getBlindSeats(state);
assert.equal(Betting.toCall(state, state.seats[bbIndex]), 0);
assert.equal(Betting.potRaiseTo(state, bbIndex), 5, "the $2 bet plus the $3 pot");
});
});
describe("after the flop", () => {
const flopWithBet = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 500, dealerSeatIndex: 0 });
Betting.beginHand(state);
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
Betting.closeStreet(state);
return state;
};
test("a pot raise facing a bet is three times the bet plus the dead money", () => {
const state = flopWithBet();
const potBefore = Betting.potSoFar(state); // $6 carried over
const bettor = state.actingSeatIndex;
Betting.raiseTo(state, bettor, 10);
const raiser = state.actingSeatIndex;
// Call $10, pot becomes $26, raise $26 → $36. Which is 3×10 + 6.
assert.equal(Betting.potRaiseTo(state, raiser), 36);
assert.equal(Betting.potRaiseTo(state, raiser), 3 * 10 + potBefore);
});
test("half pot facing that bet is $23", () => {
const state = flopWithBet();
Betting.raiseTo(state, state.actingSeatIndex, 10);
// Call $10, pot becomes $26, raise $13 → $23.
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 23);
});
test("facing no bet at all it is a plain pot-sized bet", () => {
const state = flopWithBet();
assert.equal(state.currentBet, 0, "nobody has bet the flop");
assert.equal(Betting.toCall(state, state.seats[state.actingSeatIndex]), 0);
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), Betting.potSoFar(state));
});
});
describe("it stays sane at the edges", () => {
test("a seat that doesn't exist gets zero rather than throwing", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(Betting.potRaiseTo(state, 99), 0);
});
test("the amount is always at least a call", () => {
// A raise-to below the current bet would be nonsense to offer. Every fraction
// from a tenth up lands at or above what the player owes.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
Betting.beginHand(state);
const index = state.actingSeatIndex;
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
assert.ok(Betting.potRaiseTo(state, index, fraction) >= state.currentBet,
`${fraction} of the pot should still cover the bet`);
});
});
test("cent-sized blinds don't drift", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 20, smallBlind: 0.05, bigBlind: 0.1 });
Betting.beginHand(state);
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35.
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
});
});
describe("the amounts are ones the table can actually build", () => {
const CENTS = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
const DOLLARS = [{ value: 1 }, { value: 5 }, { value: 25 }, { value: 100 }];
const table = (legend, smallBlind, bigBlind, stack = 200) => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, smallBlind, bigBlind });
state.chipLegend = legend;
Betting.beginHand(state);
return state;
};
test("a half-pot that lands on a half chip is rounded up, not offered as it is", () => {
const state = table(DOLLARS, 1, 2);
// Honestly $4.50. No dollar chip makes fifty cents.
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
assert.equal(Money.chipBreakdown(5, DOLLARS).remainder, 0);
});
test("up rather than down — a shaved pot bet is not a pot bet", () => {
const state = table(DOLLARS, 1, 2);
assert.ok(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5) > 4.5);
});
test("an amount the chips already make is left exactly alone", () => {
const state = table(DOLLARS, 1, 2);
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7, "the pot raise was already whole");
});
test("a cent table keeps its cents", () => {
const state = table(CENTS, 0.05, 0.1);
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35, which cent chips make.
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
assert.equal(Money.chipBreakdown(0.35, CENTS).remainder, 0);
});
test("a legend that plainly isn't for these stakes is not applied", () => {
// Dollar chips on a 5c/10c game means somebody never filled the legend in.
// Rounding those blinds up to whole dollars would be the tool doing real
// damage on the strength of a stale field, so the amounts stay exact and the
// wrong legend stays visible.
const state = table(DOLLARS, 0.05, 0.1);
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
});
test("a table with no legend at all gets exact amounts", () => {
const state = table([], 1, 2);
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5);
});
test("every fraction comes out buildable, and never shaved", () => {
const state = table(DOLLARS, 1, 2);
const index = state.actingSeatIndex;
const potAfterCall = Betting.potSoFar(state) + Betting.toCall(state, state.seats[index]);
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
const value = Betting.potRaiseTo(state, index, fraction);
const exact = state.currentBet + potAfterCall * fraction;
assert.ok(value >= exact, `${fraction} of the pot should never be rounded down`);
assert.ok(value - exact < 1, `${fraction} of the pot should round by less than a chip`);
assert.equal(Money.chipBreakdown(value, DOLLARS).remainder, 0,
`${fraction} of the pot should be buildable from the table's chips`);
});
});
});
+230
View File
@@ -0,0 +1,230 @@
// The house's cut.
//
// A game that rakes has chips leaving the table every hand, and the bank check used to
// call them missing: buy-ins in, chips on the felt, and a dealer told somebody had been
// paid for chips they never got. The cut is the one thing here that is supposed to go
// and not come back.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, Betting, makeTable, chipsOnTable } = require("./load");
const raking = (rake, extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, ...extra });
state.rake = { percent: 0, cap: null, noFlopNoDrop: true, ...rake };
return state;
};
describe("what the house takes", () => {
test("nothing, unless somebody says otherwise", () => {
const state = makeTable({ seatCount: 6 });
assert.equal(state.rake.percent, 0);
assert.equal(HoldemState.rakeOn(state, 100), 0);
});
test("a percentage of the pot", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 100), 5);
assert.equal(HoldemState.rakeOn(raking({ percent: 10 }), 60), 6);
});
test("no more than the cap", () => {
const state = raking({ percent: 5, cap: 5 });
assert.equal(HoldemState.rakeOn(state, 100), 5);
assert.equal(HoldemState.rakeOn(state, 400), 5, "still five on a pot four times the size");
assert.equal(HoldemState.rakeOn(state, 40), 2, "under the cap it is just the percentage");
});
test("no flop, no drop", () => {
const state = raking({ percent: 5 });
assert.equal(HoldemState.rakeOn(state, 100, false), 0, "hand ended before the flop");
assert.equal(HoldemState.rakeOn(state, 100, true), 5);
});
test("unless the house rakes every pot", () => {
const state = raking({ percent: 5, noFlopNoDrop: false });
assert.equal(HoldemState.rakeOn(state, 100, false), 5);
});
test("nothing it can't make out of the table's chips, rounded the players' way", () => {
// 5% of $30 is $1.50, and this table's smallest chip is a dollar.
const state = raking({ percent: 5 });
assert.equal(HoldemState.rakeOn(state, 30), 1);
// 5% of $10 is 50c — under a chip, so the house takes nothing at all.
assert.equal(HoldemState.rakeOn(state, 10), 0);
});
test("never more than the pot, whatever gets typed in", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 100 }), 20), 20);
assert.equal(HoldemState.rakeOn(raking({ percent: 5, cap: 500 }), 20), 1);
});
test("nothing out of an empty pot", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 0), 0);
});
});
describe("taking it out of the pot", () => {
const winner = amount => [{ id: "a", label: "Ann", amount }];
test("one winner just gets the pot less the cut", () => {
const { payouts, rake } = HoldemState.applyRake(winner(100), 5);
assert.equal(rake, 5);
assert.deepEqual(payouts.map(p => p.amount), [95]);
});
test("a split pot shares what's left, in proportion", () => {
const { payouts } = HoldemState.applyRake(
[{ id: "a", label: "Ann", amount: 60 }, { id: "b", label: "Ben", amount: 40 }], 10);
assert.deepEqual(payouts.map(p => p.amount), [54, 36]);
});
test("the rounding leftovers go back to the players, not to the house", () => {
// $100 split three ways less $7: 93 doesn't divide evenly, and every cent of
// it has to land on somebody.
const { payouts, rake } = HoldemState.applyRake([
{ id: "a", label: "Ann", amount: 34 },
{ id: "b", label: "Ben", amount: 33 },
{ id: "c", label: "Cal", amount: 33 },
], 7);
assert.equal(rake, 7);
assert.equal(Money.round(payouts.reduce((sum, p) => sum + p.amount, 0)), 93);
});
test("what goes out plus what the house keeps is what was in the middle", () => {
[[100, 5], [37.5, 1.25], [1000, 0], [9, 9]].forEach(([pot, rake]) => {
const result = HoldemState.applyRake(winner(pot), rake);
assert.equal(Money.round(result.payouts[0].amount + result.rake), pot, `${pot} less ${rake}`);
});
});
test("asking for more than the pot takes the pot, not more", () => {
const { payouts, rake } = HoldemState.applyRake(winner(20), 50);
assert.equal(rake, 20);
assert.deepEqual(payouts.map(p => p.amount), [0]);
});
test("no rake, no change — the same payouts come back", () => {
const { payouts, rake } = HoldemState.applyRake(winner(80), 0);
assert.equal(rake, 0);
assert.deepEqual(payouts.map(p => p.amount), [80]);
});
});
describe("what the box is checked against", () => {
test("raked chips are gone but not missing", () => {
const state = raking({ percent: 5 });
state.seats.forEach(p => { p.buyIns = 200; });
const before = HoldemState.bankReconciliation(state);
assert.equal(before.balanced, true);
// The house takes $10 out of the middle: somebody's stack is $10 lighter.
state.seats[0].stack = 190;
state.rakeTaken = 10;
const after = HoldemState.bankReconciliation(state);
assert.equal(after.raked, 10);
assert.equal(after.balanced, true, "the check still balances");
});
test("and are told apart from the ones that went astray", () => {
const state = raking({ percent: 5 });
state.seats.forEach(p => { p.buyIns = 200; });
state.seats[0].stack = 180;
state.rakeTaken = 10;
const check = HoldemState.bankReconciliation(state);
assert.equal(check.raked, 10);
assert.equal(check.difference, -10, "ten dollars really are missing");
assert.equal(check.balanced, false);
});
test("a table that has never raked reads exactly as it did before", () => {
const state = makeTable({ seatCount: 6 });
state.seats.forEach(p => { p.buyIns = 100; p.stack = 100; });
const check = HoldemState.bankReconciliation(state);
assert.equal(check.raked, 0);
assert.equal(check.balanced, true);
});
});
describe("the record of it", () => {
test("the hand log keeps the pot as the table saw it, and the cut beside it", () => {
const state = raking({ percent: 5 });
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 95 }], 5);
const [entry] = state.handHistory;
assert.equal(entry.pot, 100, "the pot in the middle");
assert.equal(entry.rake, 5);
assert.equal(entry.payouts[0].amount, 95, "what the winner actually took");
});
test("a hand with no rake records a zero rather than nothing", () => {
const state = makeTable({ seatCount: 6 });
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 40 }]);
assert.equal(state.handHistory[0].rake, 0);
assert.equal(state.handHistory[0].pot, 40);
});
test("a table saved before the house took anything takes nothing", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
delete parsed.rake;
delete parsed.rakeTaken;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.rake.percent, 0);
assert.equal(migrated.rake.noFlopNoDrop, true);
assert.equal(migrated.rakeTaken, 0);
});
test("nonsense in a saved rake is cleaned up rather than trusted", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
parsed.rake = { percent: -5, cap: -1, noFlopNoDrop: false };
parsed.rakeTaken = -20;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.rake.percent, 0);
assert.equal(migrated.rake.cap, null);
assert.equal(migrated.rake.noFlopNoDrop, false, "that one is a real choice");
assert.equal(migrated.rakeTaken, 0);
});
});
describe("the file the dealer hands over", () => {
const { HandHistoryExport } = require("./load");
test("has a column for what the house took", () => {
const state = raking({ percent: 5 });
HoldemState.recordHand(state, [{ id: "a", label: "Ann", amount: 95 }], 5);
HoldemState.recordHand(state, [{ id: "b", label: "Ben", amount: 40 }], 0);
// Oldest hand first, and money as bare numbers so a spreadsheet can total it.
const csv = HandHistoryExport.toCsv(state.handHistory);
const [header, raked, unraked] = csv.split("\n");
assert.match(header, /Pot,House take,Payouts/);
assert.match(raked, /,100\.00,5\.00,/, "the raked hand says how much was taken");
assert.match(unraked, /,40\.00,0\.00,/, "and the unraked one says nothing was");
});
});
describe("a raked hand, end to end", () => {
test("the chips add up: stacks plus the house's cut equal what was bought in", () => {
// 5/10 blinds, so six callers make a pot big enough for 5% to be a whole chip.
// At 1/2 the same hand makes $12, 5% of which is 60c and rounds away to
// nothing — which is the rule working, not a hand worth testing the bank on.
const state = raking({ percent: 5, cap: 5 }, { smallBlind: 5, bigBlind: 10 });
state.seats.forEach(p => { p.buyIns = 200; });
Betting.beginHand(state);
// Everyone calls the big blind, then it is checked down.
state.seats.forEach((p, i) => { if (p.inHand) Betting.putIn(state, i, Betting.toCall(state, p)); });
Betting.sweepToHandTotals(state);
const pot = state.seats.reduce((sum, p) => sum + (Number(p.amountOverride) || 0), 0);
const rake = HoldemState.rakeOn(state, pot, true);
assert.equal(pot, 60);
assert.equal(rake, 3);
const { payouts } = HoldemState.applyRake([{ id: state.seats[0].id, label: "P1", amount: pot }], rake);
state.rakeTaken = rake;
HoldemState.recordHand(state, payouts, rake);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(Money.round(chipsOnTable(state) + state.rakeTaken), 1200);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
+55 -38
View File
@@ -68,57 +68,74 @@ async function main() {
}
const browser = await chromium.launch();
const page = await browser.newPage();
const failures = [];
const warnings = [];
let checks = 0;
for (const [width, height, label] of VIEWPORTS) {
await page.setViewportSize({ width, height });
// Twice over: once with a mouse, once with a thumb. The 44px rule stands itself
// down where `hover: hover` says there's a pointer, and a headless desktop browser
// is a pointer — so a single pass here has never once measured a touch target,
// however many viewports it visited. The phone-sized ones are exactly the pass that
// needs it.
for (const touch of [false, true]) {
const context = await browser.newContext(touch
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
: {});
const page = await context.newPage();
const hand = touch ? "touch" : "mouse";
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable);
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.click("#nextHandBtn");
await page.waitForTimeout(300);
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
await page.addScriptTag({ path: AUDIT });
for (const [width, height, rawLabel] of VIEWPORTS) {
const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
const table = await page.evaluate(() => LayoutAudit.runAll());
checks++;
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable);
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.click("#nextHandBtn");
await page.waitForTimeout(300);
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
await page.addScriptTag({ path: AUDIT });
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
const table = await page.evaluate(() => LayoutAudit.runAll());
checks++;
const result = await page.evaluate(() => {
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
if (!overlay) return { offenders: [] };
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
});
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
}
}
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
});
await page.addScriptTag({ path: AUDIT });
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
checks++;
const result = await page.evaluate(() => {
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
if (!overlay) return { offenders: [] };
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
});
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
}
}
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
});
await page.addScriptTag({ path: AUDIT });
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
}
await context.close();
}
await browser.close();
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
warnings.forEach(w => console.log(` note: ${w}`));
if (failures.length === 0) {
console.log("clean");
+241
View File
@@ -0,0 +1,241 @@
// Where the seats sit on the rim.
//
// The ring used to space seats by angle, which is only the same as spacing them evenly
// when the felt is a circle. On a phone held sideways the felt is 601x126, and equal
// angles put four seats down each steep end, 20px apart, with pucks 72px tall — so a
// seat's centre belonged to its neighbour and tapping a player got the wrong one.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
// The seats as a dealer sees them: pixels on a felt of a given size.
const ringPixels = (count, width, height, rx = 44, ry = 42) => {
const aspect = width / height;
return Array.from({ length: count }, (_, i) => {
const pos = Render.positionOnRing(i, count, rx, ry, aspect);
return { x: (pos.left / 100) * width, y: (pos.top / 100) * height };
});
};
const gaps = (points) => points.map((p, i) => {
const next = points[(i + 1) % points.length];
return Math.hypot(next.x - p.x, next.y - p.y);
});
// What the ring did before: one turn cut into equal angles.
const byAngle = (index, count, rx, ry) => {
const radians = ((-90 + (360 / count) * index) * Math.PI) / 180;
return { left: 50 + rx * Math.cos(radians), top: 50 + ry * Math.sin(radians) };
};
describe("a round table", () => {
test("still spaces seats by angle, because on a circle that is the same thing", () => {
for (let seat = 0; seat < 9; seat++) {
const now = Render.positionOnRing(seat, 9, 40, 40, 1);
const before = byAngle(seat, 9, 40, 40);
assert.ok(Math.abs(now.left - before.left) < 0.2, `seat ${seat} left`);
assert.ok(Math.abs(now.top - before.top) < 0.2, `seat ${seat} top`);
}
});
test("seat one is at the top, whatever shape the felt is", () => {
[[600, 600], [601, 126], [351, 351], [949, 683]].forEach(([w, h]) => {
const first = Render.positionOnRing(0, 10, 44, 42, w / h);
assert.ok(Math.abs(first.left - 50) < 0.01, `${w}x${h} left`);
assert.ok(first.top < 50, `${w}x${h} is above the middle`);
});
});
});
describe("a felt wider than it is tall", () => {
// 640x360 landscape: the size the audit was failing at.
const wide = () => ringPixels(10, 601, 126);
const byAnglePixels = () => Array.from({ length: 10 }, (_, i) => {
const pos = byAngle(i, 10, 44, 42);
return { x: (pos.left / 100) * 601, y: (pos.top / 100) * 126 };
});
// The two seats at the right-hand end, which is where they crowd.
const endPairApart = (seats) => {
const rightmost = [...seats].sort((a, b) => b.x - a.x).slice(0, 2);
return Math.abs(rightmost[0].y - rightmost[1].y);
};
test("pulls the pair at each end apart", () => {
// They still share the end — ten seats on a rim this flat have nowhere else to
// be — but they stop sitting on top of each other.
const now = endPairApart(wide());
const before = endPairApart(byAnglePixels());
assert.ok(now > before * 1.4, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
});
test("nearly doubles the room between the closest two neighbours", () => {
// Not equal spacing in a straight line — at the ends the rim turns back on
// itself, so the same walk along it covers less ground across. Even there the
// gap goes from a third of a puck to most of one.
const now = Math.min(...gaps(wide()));
const before = Math.min(...gaps(byAnglePixels()));
assert.ok(now > before * 1.7, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
assert.ok(now > 55, `${now.toFixed(1)}px`);
});
test("never sits two seats on the same spot", () => {
const seats = wide();
seats.forEach((seat, i) => {
seats.slice(i + 1).forEach((other, j) => {
const apart = Math.hypot(seat.x - other.x, seat.y - other.y);
assert.ok(apart > 50, `seats ${i + 1} and ${i + j + 2} are ${apart.toFixed(1)}px apart`);
});
});
});
});
describe("how close the closest two seats come", () => {
// What decides whether the pucks shrink. Rim length per seat was the first answer
// and it is the wrong question: it measures the walk between two seats, not the
// gap. On a long oval the rim doubles back at the ends, so two seats a long walk
// apart end up stacked one above the other.
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
test("a flat table no longer stacks two seats at each end", () => {
// 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
// ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
// is a stadium at this shape, so an end holds one seat.
assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
});
test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
// Same felt, forced through the ellipse the tool uses for rounder tables.
const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
}));
const closest = asEllipse.reduce((least, point, i) => {
const next = asEllipse[(i + 1) % asEllipse.length];
return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
}, Infinity);
assert.ok(closest < 70, `${closest.toFixed(0)}px`);
assert.ok(gap(6, 616, 96) > closest * 1.3,
`stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
});
test("a round table's neighbours are about as far apart as the rim says", () => {
const rim = (Math.PI * 2 * 0.43 * 400) / 9;
assert.ok(Math.abs(gap(9, 400, 400) - rim) < rim * 0.15);
});
test("more seats, less room, in that order", () => {
assert.ok(gap(12, 400, 400) < gap(9, 400, 400));
assert.ok(gap(9, 400, 400) < gap(6, 400, 400));
assert.ok(gap(6, 400, 400) < gap(6, 800, 800), "and a bigger table gives more");
});
test("nothing to measure between fewer than two seats", () => {
assert.equal(gap(1, 400, 400), Infinity);
assert.equal(gap(9, 0, 0), Infinity);
});
});
describe("which rim a table gets", () => {
// An ellipse until the table is much wider than tall, then a stadium: two rails
// and a half-round cap at each end, which is how a real poker table is built.
const shape = (width, height) => {
const aspect = width / height;
return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
};
test("every layout the tool had keeps the ellipse", () => {
assert.equal(shape(844, 608), "ellipse", "desktop");
assert.equal(shape(600, 600), "ellipse", "tablet");
assert.equal(shape(278, 314), "ellipse", "phone upright");
assert.equal(shape(336, 240), "ellipse", "short phone upright");
});
test("a phone on its side gets the stadium", () => {
assert.equal(shape(528, 73), "stadium");
assert.equal(shape(780, 113), "stadium");
});
test("the cap holds one seat instead of stacking two", () => {
// Six seats on a 616x96 rim: the walk between neighbours has to be longer than
// the cap, or two of them end up on the same half-circle.
const halfWidth = 44 * (616 / 96), halfHeight = 42;
const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
});
test("seat one is still at the top centre, and the ring still mirrors", () => {
const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
assert.ok(Math.abs(seats[0].left - 50) < 0.01);
assert.ok(seats[0].top < 50);
for (let seat = 1; seat < 4; seat++) {
const mirrored = seats[8 - seat];
assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
}
});
test("nobody is drawn off the felt", () => {
Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
});
});
});
describe("the shape of the ring", () => {
test("is a mirror image left to right", () => {
const seats = ringPixels(10, 601, 126);
for (let seat = 1; seat < 5; seat++) {
const mirrored = seats[10 - seat];
assert.ok(Math.abs((seats[seat].x - 300.5) + (mirrored.x - 300.5)) < 0.5, `seat ${seat} across`);
assert.ok(Math.abs(seats[seat].y - mirrored.y) < 0.5, `seat ${seat} down`);
}
});
test("holds for the smallest and largest tables the seat count allows", () => {
[2, 3, 4, 9, 12, 24].forEach(count => {
const seats = ringPixels(count, 601, 126);
assert.equal(seats.length, count);
seats.forEach((seat, i) => {
assert.ok(Number.isFinite(seat.x) && Number.isFinite(seat.y), `${count}-handed seat ${i + 1}`);
assert.ok(seat.x >= 0 && seat.x <= 601, `${count}-handed seat ${i + 1} is on the felt`);
});
assert.ok(Math.min(...gaps(seats)) > 0, `${count}-handed seats are distinct`);
});
});
test("shares the rim out equally on a round table at any size", () => {
[3, 6, 9, 10, 24].forEach(count => {
const spacing = gaps(ringPixels(count, 500, 500, 40, 40));
const shortest = Math.min(...spacing);
const longest = Math.max(...spacing);
assert.ok(longest - shortest < 0.5,
`${count}-handed gaps ran ${shortest.toFixed(1)}-${longest.toFixed(1)}px`);
});
});
test("asked for no seats, offers no positions", () => {
assert.deepEqual(Render.ringAngles(0, 44, 42, 1), []);
});
test("puts each bet between its own player and the pot", () => {
// Where a dealer pushes the chips: on that player's line to the middle, short
// of the seat.
const bearing = (point) => Math.atan2(point.top - 50, point.left - 50);
const outFromMiddle = (point) => Math.hypot(point.left - 50, point.top - 50);
[[601, 126], [810, 583], [351, 351]].forEach(([w, h]) => {
for (let seat = 0; seat < 9; seat++) {
const player = Render.seatPosition(seat, 9, w / h);
const bet = Render.betPosition(seat, 9, w / h);
const offBy = Math.abs(bearing(bet) - bearing(player)) * (180 / Math.PI);
assert.ok(offBy < 1, `${w}x${h} seat ${seat + 1} is ${offBy.toFixed(1)}° off its spoke`);
assert.ok(outFromMiddle(bet) < outFromMiddle(player), `${w}x${h} seat ${seat + 1} is short of the seat`);
}
});
});
});
+181
View File
@@ -0,0 +1,181 @@
// Sitting people down, which is the first thing anybody does with the tool.
//
// Naming a chair and dealing that chair in were two separate acts and the second was
// easy to miss. A dealer setting up typed six names, got six seats that still weren't
// in the hand, and the action bar hid itself because by its reckoning nobody was
// playing: six people at the table and a blank screen with no way forward.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, ActionBar, makeTable } = require("./load");
const emptyTable = (seatCount = 10) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, seatCount);
return state;
};
const bar = state => Object.assign(Object.create(ActionBar), { state });
describe("the bar knows the difference between an empty table and a named one", () => {
test("a table nobody has touched gets no bar", () => {
assert.equal(bar(emptyTable()).isRunning(), false, "an empty felt stays uncluttered");
});
test("a table with names on it gets one, dealt in or not", () => {
const state = emptyTable();
state.seats[0].name = "Ann";
state.seats[1].name = "Ben";
assert.equal(Betting.liveSeats(state).length, 0, "nobody is in the hand");
assert.equal(bar(state).isRunning(), true, "but there are people at the table to say something to");
});
test("whitespace isn't a player", () => {
const state = emptyTable();
state.seats[0].name = " ";
assert.equal(bar(state).isRunning(), false);
});
test("a table that is playing still gets one", () => {
assert.equal(bar(makeTable({ seatCount: 6, seated: [0, 1] })).isRunning(), true);
});
});
describe("naming an empty chair sits somebody down", () => {
// What the seat editor's name field does, as the rule it applies rather than
// through the DOM: a chair with no name and nobody in it is empty, and putting a
// name on it is somebody arriving.
const nameSeat = (player, name) => {
const wasEmpty = !player.name.trim() && !player.inHand;
player.name = name;
if (wasEmpty && player.name.trim()) { player.inHand = true; player.folded = false; }
};
test("an empty chair gets dealt in", () => {
const state = emptyTable();
nameSeat(state.seats[0], "Ann");
assert.equal(state.seats[0].inHand, true);
assert.equal(state.seats[0].folded, false);
});
test("six names is six players, not six benched seats", () => {
const state = emptyTable();
state.defaultStack = 200; // a table that has told the tool what a buy-in is
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => nameSeat(state.seats[i], name));
assert.equal(state.seats.filter(p => p.inHand).length, 6);
assert.equal(bar(state).isRunning(), true);
assert.ok(Betting.blindPreview(state), "and there's a hand to deal");
});
test("renaming somebody who is sitting out doesn't deal them back in", () => {
// A correction, not an arrival. Dealing them in over a typo would be the tool
// overruling a dealer who sat them out on purpose.
const state = emptyTable();
state.seats[0].name = "Ann";
state.seats[0].inHand = false;
nameSeat(state.seats[0], "Anne");
assert.equal(state.seats[0].inHand, false, "still sitting out, now spelled right");
});
test("clearing a name doesn't sit anybody down", () => {
const state = emptyTable();
nameSeat(state.seats[0], "Ann");
nameSeat(state.seats[0], "");
assert.equal(state.seats[0].name, "");
});
test("a chair already in the hand is left alone", () => {
const state = makeTable({ seatCount: 6, seated: [0] });
state.seats[0].folded = true;
nameSeat(state.seats[0], "Ann");
assert.equal(state.seats[0].folded, true, "mid-hand a fold is not undone by typing");
});
});
describe("the way back from a table where everybody is sat out", () => {
test("there are named seats to offer", () => {
// Can't be reached by naming any more, but a table where everyone was sat out
// — or a save from before naming seated people — still lands here.
const state = emptyTable();
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; });
const benched = state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
.map(({ index }) => index);
assert.deepEqual(benched, [0, 1, 2]);
assert.equal(Betting.blindPreview(state), null, "no hand to deal until they're in");
});
test("dealing them all in makes a hand possible", () => {
const state = emptyTable();
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].stack = 100; });
state.seats.forEach(player => {
if ((player.name || "").trim()) { player.inHand = true; player.folded = false; }
});
assert.equal(Betting.liveSeats(state).length, 3);
assert.ok(Betting.blindPreview(state), "the table can be dealt");
});
test("an unnamed chair is never swept in by it", () => {
const state = emptyTable();
state.seats[0].name = "Ann";
state.seats.forEach(player => {
if ((player.name || "").trim()) { player.inHand = true; }
});
assert.equal(state.seats.filter(p => p.inHand).length, 1, "empty chairs stay empty");
});
});
describe("a table where nobody has bought in", () => {
// Different from a table that has busted out, and the difference is the whole
// point: one needs a starting stack named, the other needs a rebuy. Telling a
// dealer their brand-new players have "no chips left" sends them hunting for the
// wrong thing.
const neverBoughtIn = state => state.seats.every(p =>
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
test("six freshly named players have never bought in", () => {
const state = emptyTable();
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
});
assert.equal(neverBoughtIn(state), true);
assert.equal(Betting.blindPreview(state), null, "and there is no hand to deal");
});
test("a table that has busted out is not the same thing", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
state.seats.forEach(p => { p.buyIns = 200; p.stack = 0; });
assert.equal(neverBoughtIn(state), false, "they had chips — they lost them");
});
test("one player still holding chips is not a never-bought-in table either", () => {
const state = emptyTable();
["Ann", "Ben"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
state.seats[0].stack = 50;
assert.equal(neverBoughtIn(state), false);
});
test("naming a starting stack is enough to deal", () => {
// The one field the message now points at. A seat with no stack of its own
// falls back to the table default, so setting it seats everybody's chips at
// once — which is why it's the right thing to send the dealer to.
const state = emptyTable();
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
assert.equal(Betting.blindPreview(state), null);
state.defaultStack = 200;
assert.ok(Betting.blindPreview(state), "one number and the table can play");
});
});
+202
View File
@@ -0,0 +1,202 @@
// Who turns their hand over first.
//
// A call the dealer makes at every showdown that isn't a fold, and one nobody at the
// table volunteers for — everyone would rather see a hand than show one. The tool knew
// who last raised and who sits left of the button and said neither.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, makeTable } = require("./load");
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
const table = (dealerSeatIndex = 0, stack = 200) => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, dealerSeatIndex });
NAMES.forEach((name, i) => { state.seats[i].name = name; });
Betting.beginHand(state);
return state;
};
const call = state => {
const answer = Betting.showdownOrder(state);
return answer && { who: state.seats[answer.index].name, reason: answer.reason };
};
// Everybody left just calls whatever is in front of them, then the street closes.
const checkRound = state => {
let guard = 0;
while (state.actingSeatIndex !== null && guard++ < 12) {
const index = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[index]) > 0) Betting.call(state, index);
else Betting.check(state, index);
}
Betting.closeStreet(state);
};
describe("a bet on the river", () => {
test("the last player to raise shows first", () => {
const state = table(0); // blinds Ben and Cal
checkRound(state); // pre-flop
checkRound(state); // flop
checkRound(state); // turn
Betting.raiseTo(state, state.actingSeatIndex, 20);
const raiser = state.seats.findIndex(p => Betting.committed(p) === 20);
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
assert.deepEqual(call(state), { who: state.seats[raiser].name, reason: "aggressor" });
});
test("a re-raise moves it to the last one, not the first", () => {
const state = table(0);
checkRound(state); checkRound(state); checkRound(state);
const first = state.actingSeatIndex;
Betting.raiseTo(state, first, 20);
const second = state.actingSeatIndex;
Betting.raiseTo(state, second, 60);
while (state.actingSeatIndex !== null) {
const i = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
}
assert.equal(call(state).who, state.seats[second].name);
});
test("a short all-in still shows first, even though it doesn't reopen the betting", () => {
// The rule about reopening is about who may raise. It says nothing about who
// shows, and the player who shoved is still the last one who put money in.
const state = table(0);
checkRound(state); checkRound(state); checkRound(state);
const bettor = state.actingSeatIndex;
Betting.raiseTo(state, bettor, 40);
const shortStack = state.actingSeatIndex;
// Over the $40 but only by $15, where a full raise would need $40 more. Going
// *under* the bet would be a short call, not a short raise, and wouldn't make
// them the aggressor at all.
state.seats[shortStack].stack = 55;
Betting.allIn(state, shortStack);
assert.equal(Betting.committed(state.seats[shortStack]), 55, "raised, if not by enough");
while (state.actingSeatIndex !== null) {
const i = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
}
assert.equal(call(state).who, state.seats[shortStack].name);
});
});
describe("a river checked through", () => {
test("the first live player left of the button shows first", () => {
const state = table(0); // button Ann
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
assert.deepEqual(call(state), { who: "Ben", reason: "leftOfButton" });
});
test("an earlier street's raiser doesn't carry over to the river", () => {
// The rule is about the *final* round. Leading the flop and then checking the
// river doesn't oblige anyone.
const state = table(0);
checkRound(state); // pre-flop
Betting.raiseTo(state, state.actingSeatIndex, 20); // leads the flop
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
Betting.closeStreet(state);
checkRound(state); // turn
checkRound(state); // river
assert.equal(call(state).reason, "leftOfButton");
});
test("it skips a folded seat rather than naming somebody who isn't in the hand", () => {
const state = table(0); // button Ann, so Ben would be first
checkRound(state); checkRound(state); checkRound(state);
Betting.fold(state, 1); // Ben gets out on the river
while (state.actingSeatIndex !== null) {
const i = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
}
Betting.closeStreet(state);
assert.equal(call(state).who, "Cal", "the next one round who is still in");
});
});
describe("everyone all-in before the river", () => {
test("the last player who could put money in shows first", () => {
// There was no final round to lead, so "checked round" isn't a choice anybody
// made and the seat left of the button hasn't earned the obligation.
const state = table(0, 30);
checkRound(state); // pre-flop
const shover = state.actingSeatIndex;
Betting.allIn(state, shover);
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
Betting.closeStreet(state);
assert.equal(Betting.canStillBet(state), false, "nobody has chips left to bet");
assert.deepEqual(call(state), { who: state.seats[shover].name, reason: "allInAggressor" });
});
test("a hand where everyone got in for the blind and nobody raised falls to the button", () => {
// Two-dollar stacks against a two-dollar blind: everyone is all-in by calling,
// and calling is not aggression. Nobody chose to lead, so nobody is on the hook
// and it goes back to the seat left of the button.
const state = table(0, 2);
checkRound(state);
assert.equal(Betting.canStillBet(state), false, "nobody has a chip behind");
assert.equal(state.handAggressorIndex, null, "and nobody ever raised");
const answer = call(state);
assert.ok(answer, "there is still a showdown to order");
assert.equal(answer.reason, "leftOfButton");
});
});
describe("when there is nothing to call", () => {
test("one player left isn't a showdown", () => {
const state = table(0);
[1, 2, 3].forEach(i => Betting.fold(state, i));
assert.equal(Betting.showdownOrder(state), null);
});
test("an aggressor who has since folded doesn't get named", () => {
// Can't happen in play — you don't fold after raising unopposed — but the
// index is a number on the state and a reset table could hold a stale one.
const state = table(0);
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
state.streetAggressorIndex = 1;
state.seats[1].folded = true;
const answer = call(state);
assert.notEqual(answer.who, "Ben");
assert.equal(answer.reason, "leftOfButton");
});
test("an index pointing at no seat at all is ignored", () => {
const state = table(0);
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
state.streetAggressorIndex = 99;
assert.equal(call(state).reason, "leftOfButton");
});
});
describe("the tracking resets when it should", () => {
test("a new hand starts with nobody on the hook", () => {
const state = table(0);
checkRound(state); checkRound(state); checkRound(state);
Betting.raiseTo(state, state.actingSeatIndex, 20);
assert.notEqual(state.handAggressorIndex, null);
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.streetAggressorIndex, null);
assert.equal(state.handAggressorIndex, null, "last night's raise isn't this hand's business");
});
test("posting a blind is not aggression", () => {
const state = table(0);
assert.equal(state.streetAggressorIndex, null);
assert.equal(state.handAggressorIndex, null);
});
});
+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");
});
});
+188
View File
@@ -0,0 +1,188 @@
// The straddle: a blind raise put out before the cards by the player left of the big
// blind, for twice it.
//
// Home games run them constantly, and it is three rules at once — the amount, who is
// first to act, and what the minimum raise becomes. Faking one by dealing and having
// the straddler "raise" gets the last of those right and the other two wrong.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
// Six seats, everyone in, button on seat 0: small blind seat 1, big blind seat 2,
// straddle seat 3.
const table = (straddles = 0, extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0, ...extra });
state.straddleCount = straddles;
return state;
};
const dealt = (straddles = 0, extra = {}) => {
const state = table(straddles, extra);
Betting.beginHand(state);
return state;
};
describe("what goes in the middle", () => {
test("the seat left of the big blind puts out twice it", () => {
const state = dealt(1);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
});
test("a re-straddle doubles again, one seat further round", () => {
const state = dealt(2);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 8, 0]);
});
test("none of it comes from anywhere but the straddler's stack", () => {
const before = chipsOnTable(table(2));
assert.equal(chipsOnTable(dealt(2)), before);
});
test("the felt is told who straddled, not left to work it out", () => {
assert.deepEqual(dealt(2).postedStraddles, [3, 4]);
assert.deepEqual(dealt(0).postedStraddles, []);
});
});
describe("who is on the clock", () => {
test("action starts left of the straddler, not left of the big blind", () => {
assert.equal(dealt(0).actingSeatIndex, 3, "without a straddle it is the seat after the big blind");
assert.equal(dealt(1).actingSeatIndex, 4);
assert.equal(dealt(2).actingSeatIndex, 5);
});
test("the straddler still has the option when it comes back to them", () => {
const state = dealt(1);
// Everyone calls the straddle round to the big blind.
[4, 5, 0, 1, 2].forEach(seat => {
assert.equal(state.actingSeatIndex, seat, `expected seat ${seat} to be up`);
Betting.call(state, seat);
});
assert.equal(state.actingSeatIndex, 3, "back to the straddler");
assert.equal(Betting.toCall(state, state.seats[3]), 0, "nothing to call");
assert.equal(Betting.isStreetComplete(state), false, "and the street isn't over until they say so");
});
test("the straddler checking closes it", () => {
const state = dealt(1);
[4, 5, 0, 1, 2].forEach(seat => Betting.call(state, seat));
Betting.check(state, 3);
assert.equal(Betting.isStreetComplete(state), true);
assert.equal(state.actingSeatIndex, null);
});
test("the big blind acts in turn, not last", () => {
const state = dealt(1);
Betting.call(state, 4);
Betting.call(state, 5);
Betting.call(state, 0);
Betting.call(state, 1);
assert.equal(state.actingSeatIndex, 2, "the big blind is up before the straddler");
assert.equal(Betting.toCall(state, state.seats[2]), 2, "and owes the difference to the straddle");
});
});
describe("what it costs to raise", () => {
test("the minimum raise is twice the straddle, not the straddle plus a blind", () => {
// $2 blinds, $4 straddle: the next raise is to $8. Measured off the big blind
// instead it comes out at $6, and every player at the table would argue.
assert.equal(Betting.minRaiseTo(dealt(1)), 8);
assert.equal(Betting.minRaiseTo(dealt(2)), 16);
assert.equal(Betting.minRaiseTo(dealt(0)), 4);
});
test("what the table owes is measured off the straddle", () => {
const state = dealt(1);
assert.equal(state.currentBet, 4);
assert.equal(Betting.toCall(state, state.seats[4]), 4, "a fresh player calls the straddle");
assert.equal(Betting.toCall(state, state.seats[1]), 3, "the small blind owes the rest");
assert.equal(Betting.toCall(state, state.seats[2]), 2, "so does the big blind");
assert.equal(Betting.toCall(state, state.seats[3]), 0, "the straddler is already good");
});
test("a raise over the straddle is a raise, and the straddler can still re-raise", () => {
const state = dealt(1);
Betting.raiseTo(state, 4, 12);
assert.equal(state.currentBet, 12);
assert.equal(Betting.minRaiseTo(state), 20, "twice again over a raise of eight");
assert.equal(state.seats[3].hasActed, false, "the straddler has not acted yet");
});
});
describe("how many a table can take", () => {
test("everyone but the two blinds", () => {
assert.equal(Betting.maxStraddles(table(0)), 4);
assert.equal(Betting.maxStraddles(table(0, { seatCount: 3, seated: [0, 1, 2] })), 1);
});
test("heads-up, none — there is no seat left of the big blind to straddle from", () => {
const heads = table(1, { seatCount: 6, seated: [0, 1] });
assert.equal(Betting.maxStraddles(heads), 0);
assert.deepEqual(Betting.straddleSeats(heads), []);
Betting.beginHand(heads);
assert.deepEqual(heads.seats.map(p => p.committed), [1, 2, 0, 0, 0, 0]);
});
test("asking for more than the table has stops at the last seat", () => {
const state = table(0, { seatCount: 4, seated: [0, 1, 2, 3] });
state.straddleCount = 99;
assert.equal(Betting.straddleSeats(state).length, 2);
Betting.beginHand(state);
assert.deepEqual(state.seats.map(p => p.committed), [8, 1, 2, 4]);
assert.equal(state.actingSeatIndex, 1, "round to the small blind, who is first to act");
});
test("a seat with no chips is never handed one", () => {
const state = table(1);
state.seats[3].stack = 0;
// Seat 3 is sat out for the deal, so the straddle passes to seat 4.
assert.deepEqual(Betting.straddleSeats(state), [{ index: 4, amount: 4 }]);
});
});
describe("what it doesn't outlive", () => {
test("the hand it was called for", () => {
const state = dealt(1);
HoldemState.startNextHand(state);
assert.equal(state.straddleCount, 0, "nobody straddles again by accident");
assert.deepEqual(state.postedStraddles, []);
});
test("but it survives the button being pushed around before the deal", () => {
// repostBlinds takes every posted chip back and deals again; a straddle that
// vanished there would have been paid once and never returned.
const state = dealt(1);
const before = chipsOnTable(state);
Betting.repostBlinds(state);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
assert.equal(chipsOnTable(state), before);
});
test("a table saved before straddles existed reads as having none", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
delete parsed.straddleCount;
delete parsed.postedStraddles;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.straddleCount, 0);
assert.deepEqual(migrated.postedStraddles, []);
});
});
describe("what the dealer is shown before dealing", () => {
test("the straddles that are coming, and how many more it could take", () => {
const preview = Betting.blindPreview(table(1));
assert.deepEqual(preview.straddles, [{ index: 3, amount: 4 }]);
assert.equal(preview.maxStraddles, 4);
});
test("and the preview matches what dealing actually does", () => {
const state = table(2);
const preview = Betting.blindPreview(state);
Betting.beginHand(state);
assert.deepEqual(preview.straddles.map(s => s.index), state.postedStraddles);
preview.straddles.forEach(({ index, amount }) => {
assert.equal(state.seats[index].committed, amount, `seat ${index + 1}`);
});
});
});
+155
View File
@@ -0,0 +1,155 @@
// Taking the table off this device.
//
// A night lives in one browser's localStorage and nowhere else. A dealer whose phone
// dies, or who wants to carry on from the tablet after the break, loses the stacks,
// the buy-ins, where the button is and the whole hand log — and the CSV export can't
// bring any of that back, because it records what happened rather than where the
// table stands.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, makeTable } = require("./load");
const aNight = () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 1 });
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].buyIns = 200;
});
state.seats[2].stack = 340;
state.notes = "Cal owes the bar $12";
Betting.beginHand(state);
HoldemState.recordHand(state, [{ id: state.seats[2].id, label: "Cal", amount: 60 }]);
return state;
};
describe("a table written out and read back", () => {
test("comes back the same table", () => {
const before = aNight();
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
assert.deepEqual(after.seats.map(p => p.name), before.seats.map(p => p.name));
assert.deepEqual(after.seats.map(p => p.stack), before.seats.map(p => p.stack));
assert.deepEqual(after.seats.map(p => p.buyIns), before.seats.map(p => p.buyIns));
assert.equal(after.dealerSeatIndex, before.dealerSeatIndex);
assert.equal(after.handNumber, before.handNumber);
assert.equal(after.notes, before.notes);
assert.equal(after.handHistory.length, before.handHistory.length);
});
test("keeps the things a CSV can't carry", () => {
// The whole reason this exists: an export records what happened, not where the
// table stands.
const before = aNight();
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
assert.equal(after.bettingOpen, before.bettingOpen, "a hand in progress is still in progress");
assert.deepEqual(after.postedBlinds, before.postedBlinds, "so the next rotation is right");
assert.equal(after.street, before.street);
assert.deepEqual(after.seats.map(p => p.committed), before.seats.map(p => p.committed));
assert.deepEqual(after.chipLegend, before.chipLegend);
});
test("the standings and the bank check agree across the trip", () => {
const before = aNight();
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
assert.deepEqual(HoldemState.sessionStandings(after), HoldemState.sessionStandings(before));
assert.deepEqual(HoldemState.bankReconciliation(after), HoldemState.bankReconciliation(before));
});
test("it can be dealt from straight away", () => {
const before = aNight();
HoldemState.startNextHand(before);
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
Betting.beginHand(after);
assert.equal(after.bettingOpen, true);
assert.ok(after.actingSeatIndex !== null, "somebody is on the clock");
});
});
describe("what it refuses", () => {
const refuses = (text, because) => {
const result = HoldemState.fromBackup(text);
assert.ok(result.error, because);
assert.equal(result.table, undefined, "and never hands back half a table");
};
test("something that isn't JSON", () => {
refuses("not a file", "unreadable");
refuses("", "empty");
});
test("JSON that isn't a table", () => {
refuses('{"hello":"world"}', "no seats in it");
refuses("[1,2,3]", "an array of nothing");
refuses("null", "null");
});
test("a save from something else that happens to be JSON", () => {
refuses('{"kind":"some.other.tool","table":{"seats":[]}}', "wrong kind");
});
test("a file from a newer version, rather than guessing at it", () => {
// Loading it risks reading a field whose meaning has changed, and the dealer
// would find out from the money.
const backup = JSON.parse(HoldemState.toBackup(aNight()));
backup.version = HoldemState.BACKUP_VERSION + 1;
const result = HoldemState.fromBackup(JSON.stringify(backup));
assert.match(result.error, /newer version/i);
});
});
describe("what it accepts anyway", () => {
test("a bare table, with no envelope around it", () => {
// Somebody who copied the storage value out by hand. The envelope is a
// convenience, not a password.
const state = aNight();
const result = HoldemState.fromBackup(JSON.stringify(state));
assert.ok(!result.error);
assert.deepEqual(result.table.seats.map(p => p.name), state.seats.map(p => p.name));
});
test("a table from before fields that exist now, migrated on the way in", () => {
// Same repairs a save from an older version gets, because it is the same
// function — a second, looser path for imports is how a format grows two
// meanings of the same field.
const state = aNight();
const raw = JSON.parse(JSON.stringify(state));
delete raw.deadButton;
delete raw.postedBlinds;
delete raw.dealtLastHand;
delete raw.discarded;
raw.seats.forEach(p => { delete p.buyIns; });
const { table, error } = HoldemState.fromBackup(JSON.stringify({
kind: HoldemState.BACKUP_KIND, version: 1, table: raw,
}));
assert.ok(!error);
assert.equal(table.deadButton, false);
assert.deepEqual(table.dealtLastHand, []);
assert.equal(table.discarded, 0);
assert.equal(table.seats[0].buyIns, 0);
});
test("the time it was saved comes back with it", () => {
const result = HoldemState.fromBackup(HoldemState.toBackup(aNight()));
assert.ok(result.savedAt, "so the dealer can tell two files apart");
assert.ok(!Number.isNaN(new Date(result.savedAt).getTime()));
});
});
describe("the file itself", () => {
test("is named with the date and time, so two saves in a night are told apart", () => {
const at = new Date(2026, 8, 2, 21, 5);
assert.equal(HoldemState.backupFileName(at), "holdem-table-2026-09-02-2105.json");
});
test("says what it is, for anyone finding it in a downloads folder", () => {
const parsed = JSON.parse(HoldemState.toBackup(aNight()));
assert.equal(parsed.kind, "casino.holdem.table");
assert.equal(parsed.version, 1);
assert.ok(parsed.table.seats.length > 0);
});
});
+159 -14
View File
@@ -93,10 +93,44 @@ const LayoutAudit = {
return false;
},
// The modal that is currently on top, if any.
//
// Every check below asks "is this thing reachable", and while a sheet or dialog is
// open the answer for everything behind it is legitimately no — that is what a
// modal is for. Without knowing that, the audit reported the pot wizard covering
// the seats as ten faults and the rulings sheet as seven, which is the noise that
// made it useless in exactly the states nobody had audited. Now it scopes to what
// is actually on top and says so.
MODAL_SELECTOR: "#potWizardOverlay, #rulingsOverlay, #seatMenuOverlay, #cardPickerOverlay, " +
"#confirmOverlay, #addChipsOverlay, #seatEditorOverlay",
openModal() {
const open = [...document.querySelectorAll(this.MODAL_SELECTOR)]
.filter(el => !el.hidden && getComputedStyle(el).display !== "none");
// Last in the DOM wins, which is how they stack.
return open.length > 0 ? open[open.length - 1] : null;
},
// A seat sheet on a phone isn't an overlay element, it's a body class — but it
// covers the felt just the same.
modalScope() {
const overlay = this.openModal();
if (overlay) return overlay;
if (document.body.classList.contains("seatSheetOpen")) {
return document.getElementById("seatEditorPanel") || null;
}
return null;
},
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
const offenders = [];
const warnings = [];
[...document.querySelectorAll(selector)].forEach(el => {
// While something is open on top, only what is inside it can be judged. The
// rest of the page is meant to be unreachable.
const scope = this.modalScope();
const candidates = [...document.querySelectorAll(selector)]
.filter(el => !scope || scope.contains(el));
candidates.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
@@ -359,6 +393,37 @@ const LayoutAudit = {
}
},
// How much of this element actually reaches the screen, and what is behind the
// thing doing the fading.
//
// `opacity` on a box fades the whole box — its text and its own background together
// — onto whatever sits behind that box. So a dimmed seat is not "grey text on a
// dark puck"; it is a faded puck and faded text both landing on the felt. Both
// halves have to be composited the same way or the number means nothing.
fading(el) {
let alpha = 1;
let outermost = null;
for (let node = el; node; node = node.parentElement) {
const own = parseFloat(getComputedStyle(node).opacity);
if (own < 0.99) {
alpha *= own;
outermost = node;
}
}
if (!outermost) return { alpha: 1, behind: null };
return { alpha, behind: this.backgroundCandidates(outermost.parentElement || document.body)[0] || null };
},
// The two colours as the screen actually shows them, once the fading is applied.
asPainted(fgRaw, bg, fade) {
const solid = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
if (fade.alpha >= 0.99 || !fade.behind) return { fg: solid, bg };
return {
fg: this.over({ ...solid, a: solid.a * fade.alpha }, fade.behind),
bg: this.over({ ...bg, a: bg.a * fade.alpha }, fade.behind),
};
},
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];
@@ -392,12 +457,17 @@ const LayoutAudit = {
// exempt — there's nothing to read at zero — but anything in between is
// reported as unmeasured rather than passed over in silence, because a
// number nobody checked is not the same as a number that's fine.
const opacity = parseFloat(style.opacity);
if (opacity <= 0.02) return;
const faded = opacity < 0.99 ||
[...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
if (faded) {
unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
const fade = this.fading(el);
if (fade.alpha <= 0.02) return;
// A control that is switched off is the one exemption WCAG itself grants —
// an inactive component has no contrast requirement, which is why every
// toolkit dims them. Said out loud rather than skipped in silence: this is
// the only exemption in here, and the moment a dimmed thing turns out to be
// live it stops applying.
const off = el.closest("[disabled], [aria-disabled='true']");
if (off) {
unmeasured.push(`${this.describe(el)} :: disabled control, exempt from AA`);
return;
}
// aria-hidden used to exempt text here, on the grounds that decoration
@@ -425,8 +495,8 @@ const LayoutAudit = {
// The kindest reading available: best contrast across every candidate.
let best = 0;
candidates.forEach(bg => {
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
best = Math.max(best, this.contrastRatio(fg, bg));
const painted = this.asPainted(fgRaw, bg, fade);
best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
});
const sizePx = parseFloat(style.fontSize);
@@ -504,10 +574,36 @@ const LayoutAudit = {
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
}
const unmeasured = [];
this.visibleControls()
.filter(el => el.type !== "color")
.filter(el => !(exempt && el.matches(exempt)))
.forEach(el => {
// A link inside a sentence is the one target WCAG itself carves out:
// its size is set by the line it sits in, and padding a word out to
// 44px would break the paragraph around it. The rule is about controls
// a thumb goes hunting for, not words in prose. Said out loud, and
// narrow — display:inline, and real text either side of it.
if (el.tagName === "A" && getComputedStyle(el).display === "inline" &&
el.parentElement &&
[...el.parentElement.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) {
unmeasured.push(`${this.describe(el)} :: link inside a sentence, sized by the line`);
return;
}
// A control inside a scrolling sheet can sit below its container's
// visible area while still having a viewport rect. elementFromPoint at
// its centre then returns the sheet, effectiveTarget gives up, and the
// check used to pass it in silence — which is how "Confirm Winner(s)",
// the button that pays the pot out, went unmeasured for 36 rounds.
// Bring it into view before probing.
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
const box = scroller.getBoundingClientRect();
const own = el.getBoundingClientRect();
if (own.bottom > box.bottom || own.top < box.top) {
el.scrollIntoView({ block: "center" });
}
}
const rect = el.getBoundingClientRect();
// Drawn big enough is big enough — no need to probe, and probing wide
// elements is where the cap above used to produce nonsense. Seats sit
@@ -515,15 +611,35 @@ const LayoutAudit = {
// measuring their reach would report the crowding as a fault.
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
// A checkbox inside a label is tapped by tapping the label, so the
// label is the target. Measured rather than probed, because probing
// fails on anything scrolled off the viewport — which is how a 24px
// checkbox with a 335x44 label around it came up as unprobeable
// instead of as the perfectly good target it is.
const label = el.closest("label");
if (label) {
const lr = label.getBoundingClientRect();
if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return;
}
const size = this.effectiveTarget(el);
if (!size) return;
// Still can't measure it. That is not a pass — it is the check failing
// to run, and quietly returning here is exactly the kind of exemption
// that hides real faults. Say so instead.
if (!size) {
unmeasured.push(
`${this.describe(el)} :: could not be probed` +
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
);
return;
}
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
offenders.push(
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
);
});
return { name: `touch targets at least ${minimum}px`, offenders };
return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
},
// Tapping the middle of a seat must give you that seat.
@@ -537,6 +653,13 @@ const LayoutAudit = {
// Reported separately from occlusion because the cause is different: not an
// overlay on top of the table, but two pieces of the table fighting over a tap.
seatOwnership() {
// Seats behind an open sheet aren't fighting each other for taps — nothing is
// meant to reach them at all. Asking the question there produced six offenders
// per modal, every one of them the modal doing its job.
if (this.modalScope()) {
return { name: "a seat's centre belongs to that seat", offenders: [],
skipped: "a sheet is open over the table" };
}
const seats = [...document.querySelectorAll(".seat")];
const offenders = [];
@@ -598,6 +721,20 @@ const LayoutAudit = {
// ---- Runner ----
// Every check this file knows how to make, every time.
//
// Two of them used not to be here. Contrast was simply missing from the list, and
// touch targets sat behind an opt-in flag nobody passed — so "the audit is clean"
// has meant nine checks rather than eleven, and the two left out are the two whose
// faults a dealer meets in a dim room with their thumb. Both were written, tested
// and then never run: the contrast checker was built across two rounds and every
// sweep since has been silent about colour.
//
// A check that decides it can't apply here says so out loud rather than reporting
// an empty offenders list, which reads as a pass. touchTargets stands itself down
// on a mouse-driven browser — that is right, a 24px control is fine with a cursor
// on it — but "not measured" and "measured and fine" are different answers and the
// runner now prints which one it got.
runAll(options = {}) {
const checks = [
this.touchAction(),
@@ -610,15 +747,23 @@ const LayoutAudit = {
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
return {
viewport: `${innerWidth}x${innerHeight}`,
failed: checks.filter(c => c.offenders.length > 0),
// Things worth knowing that aren't faults — see occlusion() on toasts.
warnings: checks.flatMap(c => c.warnings || []),
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
// Controls a check couldn't probe go here too: not a fault, but not a pass
// either, and a silent skip is how faults hide.
warnings: checks.flatMap(c => [
...(c.warnings || []),
...(c.unmeasured || []),
...(c.skipped ? [`${c.name}: not run here — ${c.skipped}`] : []),
]),
passed: checks.filter(c => !c.skipped && c.offenders.length === 0).map(c => c.name),
skipped: checks.filter(c => c.skipped).map(c => c.name),
clean: checks.every(c => c.offenders.length === 0),
};
},
+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");
});
});