Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 edf159325b Give the seat holding the button its own middle back
On a fresh table the button is on seat 1, and seat 1 is where the first player
goes. Tapping it did nothing.

The dealer chip is a 22px disc, and a 22px disc is half the 44px a fingertip
needs, so it carried an invisible pad to catch the tap. The pad was moved twice
looking for somewhere to grow that wasn't already spoken for: outward first,
where it hung over the seat next door and gave that seat the button; then
inward, over "its own seat, which is a large target that can spare the corner".

The seat could not spare it. Measured on a 390px phone, a 72x46 puck: the pad
ran from the puck's own centre to its right edge and from its top to 3px off its
bottom, and the chip's handler calls stopPropagation. The seat holding the
button kept 52% of its face — 130 of 276 sampled points went to the chip, its
exact middle among them. Every other seat kept 97%. The button moves every hand,
so the dead puck went round the table with it.

And the chip it went to had nothing to say. Its only answer is "give the button
to this seat", and this is the seat that has it. The seat menu already knew that
and hides "Give the button" there; mid-hand the same no-op would have taken the
blinds off the felt and re-posted the same chips to announce that nothing
happened.

So the pad is gone rather than moved again — a fixed pad cannot be safe at every
puck size, and no finger reaches any chip now: the ghosts are pointer-events:
none without hover, and the one live chip is this one, which is now inert to the
pointer and still there to read. Giving the button is the long-press menu on
touch and the revealed ghost on a mouse, both unchanged. makeDealer returns
early on the seat that already has it, so the keyboard can't re-post blinds to
move the button nowhere either.

The sweep had a check for exactly this — "a seat's centre belongs to that seat",
written when the pad was reaching outward — and it ran clean through all of it,
because `seat.contains(hit)` waves through anything inside the seat and the chip
is inside the seat. The check written for this bug could not see the second half
of it. A control inside a seat is a rival for the tap now, and against the old
stylesheet it fails seven scenarios.

982 unit tests, 6 of them new; the layout sweep is 320 audits with the one
pre-existing failure (a $1,300 chip behind seat 11 on a twelve-handed 360x640
all-in).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 15:10:55 -06:00
jheaps 6e7b047d3e Merge pull request 'Hold'em dealer: a table that reads its own showdown, and a tool that is there with no signal' (#120) from polish/holdem-game-night-ready into master
Build and Deploy / build-deploy (push) Successful in 1m14s
Reviewed-on: #120
2026-09-09 14:23:50 -06:00
jheapsandClaude Opus 5 171e878262 Give the felt back the sixth of the phone the title was holding
The header stood 120px tall on a 390x844 phone and only 46 of that was a
control. The other 74 were a 22px page title with a gold rule under it, a back
link, and a gold "Hand #1" pill — three things read once, above a screen whose
whole subject is the green table underneath. Sixteen percent of the window,
spent before the felt started.

A phone held sideways had already solved this. The landscape block clips the
title and puts everything on one row, because at 390px tall there was no other
option. Upright never got the same answer, and upright is how this tool is
held. It is 44px now, the table starts at 78px instead of 152, and the felt
went from 496px to 570 — about fifteen percent more table on the same screen.

Nothing left the page:

- The title is still the document's <h1>, clipped rather than removed, so it is
  still the heading a screen reader lands on and still what the browser tab and
  the installed app are called.
- The back link keeps a 44px box and loses only the word "Casino", which is what
  every phone does with a back link. The name a screen reader gets is on the <a>
  either way, and it contains the visible word on the sizes that still show it.
- The hand number moved to the action bar's status row, in front of the street.
  That row already says what hand you are in — the street, the pot, the player
  count — and this is the fourth fact of the same kind; a counter read after the
  fact had no business holding a line of the header all night. It costs the bar
  nothing: the five audit scenarios that report the bar's height report the same
  249, 227, 203, 227 and 227 pixels as before.

The three buttons take the width the title gave up rather than shrink-wrapping
to the right of it, which is 26px of extra target each. `flex: 1 1 0` was wrong
here — it divides the row into equal thirds and squeezes "Next Hand ⟳", 105px of
text, into 99, and these are nowrap, so what gives is the label. An `auto` basis
makes the label the floor and the spare width the bonus. On the narrowest phone
in the sweep the arrow and all three buttons come to 323px of 336.

The arrow was 32px on the first pass and the layout sweep caught it at 31x43 on
every touch viewport there is — 56 scenarios, one control. The word had been
carrying the width; with the word gone the arrow is the whole target. 44 square.

The sweep is down from five failures to one. Four of the five were seats and bet
chips colliding on a 360px Galaxy S8, and 76px more felt was all they needed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 08:11:21 -06:00
jheapsandClaude Opus 5 0f7213ec2d Open a seat on what is on it, and put the sheet in front of the table
The seat sheet is the second-most-used surface here — every buy-in, every
rename, every correction goes through it — and it was the least finished one.
Seven things were stacked flat at one weight and all open at once. Measured on
a 390px phone it came to 594px and scrolled, and the three controls a dealer
touches on nearly every visit were 132px of that. The chip counter alone was
174px, the tallest single thing in the sheet, for a job most tables do once a
night or never; the two hole-card slots were 62px of dashed grey on every table
that never asked to be shown a card.

Cards, the contribution and the custom fields are behind a fold now, and which
of the three stand open is a question about the seat rather than a setting: a
seat holding cards opens its cards, a seat with chips counted opens its count,
a seat with a note on it opens its notes. A shut section says the answer on its
own row — the cards, the total, how many notes — so nothing already entered is
further away than it was. An empty chair is 419px and does not scroll; a seat
with everything filled in is 625 and opens all three.

The fields have the width the sheet always had. `.fieldRow input` caps at 140px,
which is right in a 320px sidebar of settings and wrong in a sheet holding a
whole phone: "Name" sat against the left edge with 203px of nothing between it
and a field a third of the row wide. Same 44px control, 300px instead of 140.

The chip counter's values are text. They were a `title` attribute, which is a
tooltip, which is nothing on the device this is used on — five identical number
boxes told apart by five coloured dots. It also left the wrapping <label> with
no text in it, so they had no accessible name either.

And the sheet has a scrim. It was the only one of the tool's seven overlays
without one, while covering the felt from 250px down and the action bar with
it — modal in everything but appearance. What stayed lit above it was the
header, with Misdeal, Next Hand and Resolve Pot live under a thumb reaching for
a name field. It dismisses on a tap and on Escape now, like the other six; a
card picker opened from inside still takes Escape first. In landscape the scrim
stops where the sheet stops, so Fold, Call and Raise keep the rail.

It is put away with visibility rather than opacity, which is the same lesson the
sheet above it already learned about transforms — and the layout sweep caught it
the same way. At `opacity: 0` a fixed, full-window box is invisible and still
entirely there: bottomFurniture() read it as furniture standing 640px tall at
the bottom of a 640px phone, in every state and on every screen, and asked
scroll-padding to reserve the whole window for it. Ninety-eight scenarios failed
on it; hidden is hidden, and the sweep is back to the five it started with.

The toast steps over the sheet the way it already steps over the action bar.
Both are fixed to the bottom of a phone, and a "Keeping the screen on" toast
was landing squarely on the seat form with its Undo and its × live above the
fields. The height is measured and published, because it changes the moment a
section is unfolded.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 07:29:03 -06:00
jheapsandClaude Opus 5 853fcedea0 Measure the text the way an eye reads it, not the way a ratio scores it
The sweep has checked WCAG AA contrast for a long time and the page passed. That
result was worth less than it looked.

WCAG 2's ratio is a luminance gap, and a luminance gap overstates readability once
one colour is near black — which is every reading on a page whose ground is #141414.
Tuning a colour until it clears 4.5:1 there lands on text that passes the audit and
still reads as a smudge across a table in a dim room. Several rules in this
stylesheet said as much in their own comments, one of them "measured 4.5:1 at
#a5a5a5 exactly, so it sits above that": the right instinct through the wrong lens.

The check also could not see half the backgrounds it scored against. It reads
background-color and takes the best contrast of any candidate ancestor, so text on
the felt was scored against the dark page behind the felt. Two plain AA failures sat
in a clean report for as long as the check has existed — the chair number on the
brown rim at 2.65:1, and the pot's own label, gold on green, at 2.66:1.

runContrastAudit.js scores with APCA and gets the background by screenshotting the
page with its text made transparent and reading the pixels, which no gradient or
shadow can fool. The first version walked the DOM and called the gold dealer button
black-on-black at Lc 0.4; that false positive is why it works this way.

Found: 65 of 92 pieces of text below their level, 54 passing AA at the same moment,
worst Lc 28. Now nothing is below Lc 60, nothing fails AA, and the worst is Lc 60.4.

Three fixes worth naming, none of them "make the grey lighter":

- A player's stack was secondary text at Lc 44, under a name in --text at a heavier
  weight. That says a dealer needs the name more than the number; during a hand the
  opposite is true. Hierarchy comes from size and weight now, which is free, rather
  than from fading, which on a dark ground is the most expensive way to say it.
- An empty seat had no background of its own, so the felt decided how readable its
  number was: Lc 28 on the rim, 30 on the green, 33 further in. It has a ground now,
  at 0.62 and not the 0.82 first written — contrast is nearly flat across that range
  and the heavier wash put four dark rectangles on the felt like holes.
- The pot's label sat on the one green in the design. The disc is deeper rather than
  the label bleached, because gold on dark green is the pairing this page is built
  on. Deepening only its outer stop moved it two points: the highlight is at 32%/28%
  and that is exactly where the label sits.

apca.js is checked against the eight published reference pairs, png.js against
round-tripped fixtures for every filter. Four fault injections caught; a fifth
turned out to be a no-op — the Paeth predictor is symmetric in its first two
arguments over all 16,777,216 byte triples, so that swap is untestable, which is
written down rather than papered over with a fixture that appears to catch it.

Still below the strict APCA level: 55, all of them 10-12px labels that would need
white type or a bigger seat puck. Reported, not chased, and said out loud in the
sweep's own notes.

Layout sweep unchanged: 5 FAILs, 14 offender lines, no regressions. 950 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 06:40:28 -06:00
jheapsandClaude Opus 5 295d9eff4a Stop a scroll being an edit, and a stack changing without being touched
A focused <input type="number"> is a spinbutton, and a browser lets the mouse
wheel drive it. Measured on the running page at 1280x900 with the sidebar
open: focus the small blind and scroll, $5 becomes $5.50. Focus a seat's stack
and scroll, $500 becomes $501. Both go straight through to localStorage,
because these fields save on `input` like everything else here.

Nothing is announced and nothing looks wrong. The tool has invented a chip, on
a table whose whole purpose is that the chip count is right, and the gesture
that did it was a dealer reaching for another panel.

The rule is narrow on purpose. Every other way of driving a spinbutton is
deliberate -- the arrow keys, the stepper arrows, typing -- so scrolling is
the only one taken away. Verified after: the value holds, one ArrowUp still
moves it, typing still works, and the page still scrolls.

Blur rather than preventDefault. Cancelling the event stops the value moving
and stops the page moving with it, which trades a silent bug for a sidebar
that mysteriously will not scroll -- a problem the dealer can see and cannot
explain, instead of one they cannot see at all.

The decision lives in Money as a predicate beside selectsOnFocus, so the rule
is testable without a browser and main.js only does the blurring.

Two things checked rather than assumed. The tagName guard in the predicate is
decoration: deleting it passes every test, because nothing but an <input>
carries type="number". That is now said in the comment instead of being left
to look load-bearing. And the page's one <select> -- which way the blinds move
-- is not vulnerable: focused, hovered and scrolled 300px in Chromium it stays
where it was, so it is left out of the rule rather than defended against.

916 tests pass, 6 of them new. Four fault injections into the predicate are
caught; a fifth was applied, not caught, and turned out not to be a fault.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:46:20 -06:00
jheapsandClaude Opus 5 fafbd8df32 Stop the things that actually move for anyone who asked them not to
The stylesheet had honoured prefers-reduced-motion for a while, in the three
places it mattered least: a 2px press-down on a button, a checkbox tick, and
the acting seat's pulse. What none of them covered was motion that moves
something across the screen, which for anyone with a vestibular disorder is
the part that matters. A button dipping under a thumb is not what makes people
ill.

Four things were doing it, and the three largest were in JavaScript where a
stylesheet cannot reach: the seat editor sheet, which slides the full height
of itself up from off-screen every time a dealer taps a chair; the toast; and
three scrollIntoView({ behavior: "smooth" }) calls, the most provocative of
the lot because what moves is the whole viewport rather than one control.

The scrolls go through Render.bringIntoView now, which reads the setting per
call rather than at page load -- a dealer who turns it on mid-session should
be taken seriously without reloading a table they cannot afford to reload. The
toast keeps its fade and loses its travel, because something that appears
already in place reads as a rendering glitch rather than as news. The sheet's
visibility delay flips at once, or it would leave a sheet that has already
jumped away still painted for a quarter of a second.

The press-down block held nine :active selectors and looked deliberate. It was
written to stop a stuck press state when a tap turns into a scroll, and had
reached for the reduced-motion query as the nearest proxy -- so it covered
.dealerChip and .raiseQuickBtn while missing .actionBtn, the class on Fold,
Call, Raise and Post blinds & deal. Read as its own rule the membership is not
a judgement call: every :hover and :active transform in the file belongs
there. There are sixteen. Nine were listed.

The scan that found the other seven took two goes. A line-by-line version
walked straight past a deliberately broken build, because the injected rule
was written on one line and neither "opens a rule" nor "is a transform"
matched. Parsing blocks caught it -- and then failed on the clean file, which
is how .toastAction, .toastClose and .potWizardChoice came to light: a
multi-line group had been truncated to its last line, so nothing had ever
looked at them.

910 tests pass, 16 of them new. Six script and stylesheet fault injections are
each caught, and the reduced/no-preference split is verified in a real browser
against both context settings.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:21:07 -06:00
jheapsandClaude Opus 5 0d878a176d Say in the sweep's own notes where the last five failures actually live
315 of 320 audits pass. The five that do not are one root cause on the two
smallest screens: twelve seats want about 60px of footprint each and the ring
has 44px of rim to give them, in the two states that make the bar tallest.

Written down rather than left to be rediscovered, because closing it is a
design decision -- drop the bet chips at extreme crowding the way the tight
tier already drops the hole cards, cap the seat count by viewport, or let the
felt scroll -- and each trades something a dealer can see.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:03:33 -06:00
jheapsandClaude Opus 5 7216f3da4d Stop a wrapped label costing twelve seats their room
The sweep had carried the same residual for rounds: on the two smallest
phones with twelve players, seats painting over each other's names and
stacks. It read like a geometry problem. It was a wrapped label.

"ALL IN" is the only thing that ever lands on the stack line with a space in
it. A stack figure has no spaces, so it can only overflow, and the
puckAmountLong/Huge sizes exist to stop that; nothing had asked what the line
does when it can break. Measured at 360x640 with twelve players all-in, it
wanted 36px and had 34, broke onto a second line, and an 11px line became 22.

That is not a typography complaint, which is why it survived: it looks fine.
The puck went from 55px tall to 66, and puck height is what the ring is short
of -- at the ends of an ellipse consecutive seats are separated vertically, so
a puck 11px taller is a puck 11px further into its neighbour. The layout
search then did exactly what it is written to do: tried every width it has,
found none that cleared, and fell back to the narrowest, which is also the one
that wraps hardest.

So the rule every design system gives a badge applies here too: one line,
always. Not wrapping is only half of it -- a line that cannot wrap and still
does not fit paints over the seat next door instead -- so on the tight tier
the mark gives up its letter-spacing, 3.6px of the 36 it wanted, and keeps the
warm colour and the bold weight that do the same job.

Halving the overlap did not clear it, and the rest of the shortage was not on
the felt. The sweep had been saying so in a warning rather than a failure: at
360x640 the bar was 267px of a 640px window, 42%, against its own "persistent
chrome under 30%". Two things in it were being shown when they could not be
used. The side-pot list is decision support, and during a run-out there is no
decision to support -- it collapses to the main pot and one line for the rest.
The status row said the player count twice in the same breath, as a chip and
again inside the run-out sentence beside it.

Measured: bar 267 -> 249 on a Galaxy S8 and 267 -> 227 on an iPhone SE, felt
243 -> 283 there, worst seat overlap 34px -> 4px. Across the sweep, overlap
offender lines 33 -> 9, chips behind seats 30 -> 5, failing audits 6 -> 5, and
the iPhone SE's seat-text overlap gone entirely. No new failures.

894 tests pass, 9 of them new. Seven fault injections are each caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:01:56 -06:00
jheapsandClaude Opus 5 e2bb935b43 Be there when the signal, the screen and the storage are not
Three failures that only ever happen at a table, none of which show up on a
desk with a good connection and a plugged-in laptop.

The offline promise depended on how the URL was spelled. Razor Pages routing
is case-insensitive, so /Casino/TexasHoldem serves the identical page; a
service worker's scope is matched as a plain string prefix, so a worker scoped
to /casino/ does not control it. The registration succeeded, the worker
activated, nothing was logged, and the cache stayed completely empty --
because the precache list was handed to navigator.serviceWorker.ready, which
resolves with the registration controlling the page and so never resolves at
all outside its scope. A dealer who opened that URL to load the tool for the
night had loaded nothing.

One spelling wins now, via a 301 scoped to /casino rather than applied
site-wide: every file under wwwroot/casino and wwwroot/images/casino is
already lowercase, so lowercasing there cannot miss a real file on a
case-sensitive filesystem. That guarantee does not hold for the rest of the
site, and /chessHub would break. The handshake is fixed rather than left to be
saved by the redirect -- it takes the worker off the registration itself.

The manifest icons were never cached, because the shell list is read off the
DOM and the DOM never mentions them. They are read out of the manifest now,
cache-first so an offline launch stops logging ERR_INTERNET_DISCONNECTED, and
the link sweep picks up apple-touch-icon, which is the only one iOS reads.

The screen slept mid-hand. A wake lock is held while betting is open and
dropped the moment it is not: through a whole street of renders it takes
exactly one, two syncs in a tick take exactly one, a hand that ends inside the
request leaves nothing held, and the browser taking it back leaves us able to
take it again. Announced once with a Turn off button rather than a permanent
header indicator; a setting after that.

And localStorage is evictable, which the Save & reset panel now says out loud.
persist() is asked for on Post blinds & deal, because every browser wants that
request to come from something the dealer did.

885 tests pass, 18 of them new. Five fault injections into the wake lock are
each caught. One of the new tests was green for the wrong reason first:
globalThis.navigator = fake does nothing, because Node has defined navigator
as a getter since v21.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 04:31:45 -06:00
jheapsandClaude Opus 5 75ec028c3e Ask for the hands at the showdown, the way the streets are already asked for
The card feature had two halves and only one of them ever asked a question. Turn the
setting on and every street opens a sheet for the cards coming off the deck — that
worked, on a phone and on a desk, in a normal hand and in an all-in run-out. The
other half never asked at all. A dealer who entered all five community cards, pressed
"Showdown — resolve pot", and got a wizard saying

    Christopher, Mike from work, Alexandra and Bob haven't turned over, so who
    won is still yours to say.

with nothing in that sheet to turn anybody over with. The only route in was to close
the wizard, tap a seat puck, find the two card slots in the seat editor, do it four
times and re-open the wizard, with the pot on the table and four people waiting. The
one moment the tool is certain to know the hands is the moment it stopped asking.

So the showdown button asks. The sheet opens on whoever the bar has just named as
showing first, takes their two cards, and moves round the table from there; hands
already entered are skipped, because a hand entered is a question already answered.
Four sheets later the wizard opens reading "Chris wins $40 with three of a kind,
nines" and its button says "Pay Chris" instead of "Confirm Winner(s)".

Leaving the sheet stops the asking rather than skipping a player: closing it says the
tool has the wrong end of the stick, and the answer to that is to get out of the way.
It isn't a dead end — the wizard still names whoever didn't turn over, the bar still
says "Showdown — resolve pot", and pressing it again resumes at the player the dealer
stopped on, because the list is read off the cards on the table rather than remembered.

CardPicker.close() takes a reason so the chain can tell "that sheet filled its last
slot" from "the dealer left". Two callers pass nothing and don't care.

The setting kept its behaviour and lost its jargon: "Track the board and read the
showdown" was the feature's name rather than what turning it on does to your evening.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 04:10:53 -06:00
jheapsandClaude Opus 5 e90bcccada Spend the accent on the one thing to do next, not on two at once
The green is a promise: this is the step. Measured over a whole hand — seventeen
steps — it was on two controls at thirteen of them, the bar's Call and the header's
Resolve Pot painting the identical gradient at opposite ends of a phone, and on the
header alone at the other four while the bar held the only button there was to press.
At showdown the most consequential tap in the hand, "Showdown — resolve pot", was
blue-grey while the corner shortcut was green.

So the accent moves to .actionAdvance, which is always the next step by definition,
and comes off the header shortcut, which never is. Twenty-two measured steps of a
real hand now show exactly one.

The sweep checks it: oneNextStep() counts the accent-coloured controls that are
actually on screen, scoped to an open sheet when there is one. Six broken versions
were run at it, and the two that took three goes to catch were both the same mistake
in the injection rather than a hole in the check — a button painted green with the
panel shut isn't rendered, and one painted green below the fold is deliberately
ignored. Scrolled into view it is named alongside the bar's Call, and goes clean
again when the green comes off.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 04:10:53 -06:00
jheaps 14fb68dc04 Merge pull request 'Hold'em dealer: making the table and the bar fit the screen they are on' (#119) from polish/holdem-round-next into master
Build and Deploy / build-deploy (push) Successful in 1m40s
Reviewed-on: #119
2026-09-08 19:08:12 -06:00
jheapsandClaude Opus 5 4a1cb842cb Say on each shut sidebar panel the one thing you'd open it for
The sidebar is eight <details>, and every one measured exactly 60px shut with
nothing on it but its own label — 480px of a phone's screen, and 480px of a 660px
desktop column, spent saying "Standings", "Hands", "Waiting list". Whether the box
balances, whether anyone is waiting for a seat, how many hands have been played:
the page had all three worked out already, and charged a tap for each of them, on
rows two thirds of which were empty.

That is the disclosure pattern's named failure — a header you have to open to find
out whether it was worth opening — and the answer the design systems give is the
same one: put the status on the header. The panel keeps the sentence. "Off by
$300" is the alarm; which seat is holding chips nobody wrote down is a paragraph,
and that is the half that belongs behind the tap.

Two rules, and panelGlance.test.js checks both. A glance appears only when it has
something a dealer would act on — eight rows that always say something are eight
rows that say nothing again, and a row reading "Nobody waiting" all night is the
clutter this went in to remove. And a glance never says something the panel under
it would contradict: it is read off the same state, never off the panel's DOM,
which for a shut <details> isn't rendered at all.

Absolutely positioned rather than floated. A <summary> only draws its disclosure
triangle while it is display: list-item, so it cannot be a flex row; the float
that replaced it kept the row at 60px but aligned to the label's line box, and a
12px string in a box sized for 15px text rode 2.5px low on all six panels. Out of
flow is also the smaller promise — an absolutely positioned box cannot make its
row taller at all.

Three paths deliberately skip renderAll, because it would take the field out from
under whoever is typing in it, and all three move something a glance reports. The
notes textarea, the chip legend's value fields and commitSeatChange each call
renderPanelGlances on their own instead; without that the note you had just typed
was summarised by the note before it until the next tap landed. A space between
the label and the glance, too: the summary is announced as one button, and without
it a screen reader was given "Table Setup9 seats".

Costs 0.08ms of a 4.79ms renderAll on a twelve-handed phone. Four injected faults
in the wording are caught one test each.

glancesFitTheirRows took three goes. The first asked only whether a row had grown
taller than a bare one, and a glance floated back into flow and allowed to wrap
sailed straight through it, because a float does not expand the box it sits in.
The second was caught by the sweep on its first clean run — 94 failing configs, 91
of the offenders this check's own: it takes the shortest row as its idea of a bare
one, and the Keyboard panel is display: none under pointer: coarse, so on every
viewport visited with a thumb the bar was a row that wasn't drawn, and all seven
real rows read as grown. Rows that aren't rendered aren't rows. The four broken
layouts are re-run with that panel hidden and each is still caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-06 01:56:58 -06:00
jheapsandClaude Opus 5 e7ddff2fed Let a large felt have larger seats, and the type that goes with them
The felt grows with the window and the pucks did not: 96px was the only
ceiling there was. Nine-handed, the seats covered 19% of the felt on a phone,
11% on a 1280px desktop and 8% at 1440px and beyond — and the stack figure a
dealer reads sat at 10px on a 2560px monitor, the same 10px as on a 360px
phone, well under every published floor for text meant to be read.

Two things stop this costing more than it buys.

Every candidate width is a forced layout. Simply raising the ceiling took the
layout pass from 24ms to 73ms twelve-handed on a desktop, so a screen that
cannot use the wider widths must not measure them: the extra pass is offered
only when the arithmetic already says the seats have room to spare, and a phone
measures exactly the ten widths, in the same order, that it measured before.
Timed with the order alternated so neither carries the warm-up: 10.7ms against
10.4ms on a phone, and +3 to +6ms where the widths are actually used.

And a wide puck on a narrow felt is worse than a small one. The first version
of this let phones through, and a 144px puck on an iPhone's 278px ring is half
the width of the table: six-handed, all six seats sat over the rail by 15px
against 5px before, reading as a stack of cards rather than a table with people
around it. So the ceiling is a fifth of the felt's shorter span, capped at 144.
Five is measured rather than chosen — the shorter span is 233-303px on every
phone in the sweep including sideways and 617-714px on tablets and desktops, so
any divisor between 3.2 and 6.4 separates them — and the test pins both ends.

Above the ordinary ceiling the puck takes the larger type as well, which is the
point of the extra width. Nine-handed: an iPad goes 96px/12px to 134px/15px, a
1440px desktop 96px/12px to 140px/15px, and the seats go from 8% of the felt to
12%. Every phone, at every seat count, is untouched.

The sweep is byte-identical to before this change: same six configurations,
same 63 offenders, empty diff.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-06 00:56:59 -06:00
jheapsandClaude Opus 5 f8ed4abc85 Stop two empty rows in every seat puck setting the size of the table
A seat puck carries the name, the hole cards, the amount that player has put
in, and their stack. Two of those are empty most of the time and both held
their space anyway.

The amount pill was worse than empty space. It has a background, a pill radius
and side padding, so with nothing in it, it painted an 8x11px green lozenge —
measured on seven of nine seats at an ordinary table. One of those is a
component holding its place; nine is a rash, and a dealer has to learn to
ignore a colour that on the other two seats means "this player has money out".

The space was not free either. seatRingRadii sizes the ring from the tallest
puck on it, so two rows nobody had filled were setting how far apart the whole
table had to sit. Across 21 tables — seven viewports, six, nine and twelve
handed — every single puck comes back shorter and six come back wider: an
iPhone 14 nine-handed 78x88 -> 78x63, twelve-handed 44x65 -> 66x55, which is the
one name in the set that was clipped and now is not.

The collapse is only safe because the layout cache moved with it.
resolveSeatLayout measures ten candidate widths, each a forced layout, against
seatShapeSignature() — which listed the names and the dealer seat, everything
that could wrap a name onto a second line. It did not mention these two rows,
because while they always held their space they could never change a puck's
height. Now they can, so the signature names them: booleans over the whole
table, because measuredSeatBox takes the tallest puck on the ring, and because
a per-seat key would be correct and would throw the cache away on every tap.

The ring settles by 24-26px when the blinds go in — and it settled by 23-25px
there before this round too, since the action bar changes height at that moment
and the felt is measured against it. The one place it moves is a place it
already moved.

uiAudit gains emptyDecoration(): nothing on the felt may paint a background or
a border with no content in it. Its one exemption is on the list because the
check reported it rather than because it was predicted — .betChipDisc is a
circle filled with the denomination colour of the chip beside it, so the colour
is the content. Three others were written from memory first and matched no
element on the page at all.

The sweep is unchanged at 6 failing configurations and 63 offenders. All four
table failures are the all-in shape, where every pill is filled and the
collapse buys nothing; the ordinary tables this helps were already passing, so
what moved is room a name has rather than whether anything was covered.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-06 00:09:25 -06:00
jheapsandClaude Opus 5 942919f3f7 Stop the side-pot list growing the action bar over the felt
The bar is fixed over the table, so its height is the felt's budget — and it
carried a pot breakdown that drew one row per all-in with no ceiling. On a
360x640 phone: five players idle put the bar at 151px, six all-in at 303px,
twelve all-in at 411px. That is 64% of the screen, leaving 96px of felt to draw
twelve seats in, and at that size the seats stop being a table: five pairs
collapse onto each other's centres, ten hang past the rail and a dozen labels
sit completely covered.

Past four layers the bar now draws the main pot, one line for everything
between, and the newest side pot. The two ends are the live ones for the same
reason — side pots are paid from the newest back down to the main pot, so the
top layer is the next thing that happens and the main pot is the last, and
those are the two a dealer says out loud while the chips are stacked. The
middle is the rest of that count-down, wanted at showdown, which is the pot
wizard: same builder, no cap, in a dialog that scrolls. The elided line carries
the count and the money, so the visible rows still add to the announced pot.

Nothing in the sweep could see any of this, and the reason is worth keeping.
Every check asks whether an element is covered or clipped; none asked how much
room the covering element had taken. But the deeper miss was the sweep itself —
all 294 audits drove their table with Call and Check, so no table it ever
measured had a player all-in, and the one thing on the bar whose height depends
on the table was empty in every measurement. There is now a fourth table shape,
twelve players shoved in for twelve different amounts, and furnitureBudget()
measures the fixed band against the window: failing over half of it, warning
over the 30% that persistent chrome is usually held to.

Galaxy S8, twelve all-in: bar 411px -> 267px, felt 96px -> 192px, 91 layout
offenders -> 25. iPhone SE: 73 -> 10. The rest is the seat ring on a short
phone, which the sweep can now see.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 23:34:51 -06:00
jheapsandClaude Opus 5 02c2a034cb Stop the fixed bar and the toast sitting on the keyboard's focus
WCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden
by something the page has put on top of it. The bar is fixed over the
bottom and is not in the flow, so the browser does not know it is there:
Tab to a control near the bottom and it is scrolled flush against the
edge of the window, under the bar, and the browser considers it done.

Tabbing the whole page measured 16 stops resting completely covered on a
390x844 phone and 27 more partly, plus four under a toast on the desktop.
It is now 0 and 0 on desktop, phone and a phone on its side — clean
against 2.4.12 (AAA) as well, which allows no overlap at all.

Three things were wrong and they needed different answers.

scroll-padding-bottom on the scroll container, sized from the bar's own
published height and the toast's, handles the case where the browser has
to scroll to reach the control. That is half of it, and the half that
looks like all of it: a control the browser already considers visible is
one it will not scroll to, and a fixed overlay on top does not enter into
that decision. Measured — tabbing to Common rulings with a toast up left
it 100% covered and scrollIntoView({block:"nearest"}) moved nothing. So a
focusin handler measures the overlap and scrolls by it, and when the page
has no room left (26px against 94px wanted) the toast gives way instead.
Sizing the page padding for the toast would have fixed that by making the
page grow and shrink 64px whenever anything was announced.

The page's room to scroll past the bar was 9rem on the sidebar alone,
which left the table's column — most of a desktop — with none. It is now
on the whole app and taken from the bar's real height, so it is also 0 in
the layout where the bar is a rail down the side.

And the seat editor's bottom sheet was put away with a transform, which
moves a thing without putting it away: its Done button sat at y=850 on an
844px screen, focusable, announced, and impossible to scroll to, because
a fixed element does not move when the page does. visibility:hidden puts
it away, transitioned so the slide-out still runs.

uiAudit.js gained focusHasRoom() and focusablesReachable() for the two
static halves. Audit unchanged at 2 failing configurations and 3
offenders, both pre-existing. 805 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 22:57:58 -06:00
jheapsandClaude Opus 5 cd2db6346e Stop a chip colour taking the table's money with it, quietly
Deleting a chip colour was one click of an unlabelled 24px button, and it
was never a change to a legend. computeContribution() prices a seat by
running the legend over that seat's chip counts, so a colour that leaves
takes every chip counted in it off the table.

Measured in a browser on a three-handed table: deleting the $500 row took
$22,500 of $26,874 with it. No confirmation, nothing said afterwards, and
no way back — a colour added again is a new colour with a new id, while
the counts stay keyed to the one that left. The counts were left behind
too, so the saved file carried money nothing could price and nothing
could show.

Now: a colour nobody has counted still goes on one click, and one that is
holding money asks first, naming the amount, the chips and the seats, and
saying that Undo will put it back while adding the colour again will not.
Conditional on purpose — a confirmation that fires every time is one
nobody reads by the third night, and four others already compete for that
attention here. Removal goes through runUndoable, and the counts go with
the colour so the file matches the screen.

The number in that warning is the difference between pricing every seat
with the colour and without it, not chips times value. The obvious
arithmetic is wrong both ways: a seat with a typed amount is never priced
from its counts, and a seat whose chips all leave falls through to the
table's standing ante rather than to zero. A randomised check over 2,000
tables asserts the warning equals what the table then does, and it found
a third case — fourteen $1 chips against a $200 ante, where removing the
colour puts the table UP $186. The wording says so in that direction too.

The × also had no accessible name, so uiAudit.js gained controlNames():
every control needs a name containing a letter or a digit, resolved the
way the accessibility tree resolves it. A symbol is not a name. Taking
the new aria-label off again makes it report all five buttons.

Audit unchanged at 2 failing configurations and 3 offenders, both
pre-existing. 805 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 22:26:16 -06:00
jheapsandClaude Opus 5 6eb97c8a35 Give the sidebar eight names instead of five and a cupboard
"Session" was a container word rather than a name. It held the standings,
a waiting list, the hand log and the save/load/reset buttons — a topic, a
task, a log and three utilities under one label, which is a navigation
layer with more than one logic in it.

It measured 1206px and 103 visible elements, against 555 and 29 for the
next biggest, and "The table itself" sat 1052px down inside it. Reaching
Reset table meant opening a panel called Session and scrolling past the
money and the log to get to it.

It is now Standings, Waiting list, Hands and Save & reset, each named for
the thing a dealer came looking for, in the order of a night: set the
table up, watch the money, fill the seats, look back at a hand — and,
last and apart, the two buttons that end it. Reset table goes from 1052px
inside a panel called Session to 62px inside one whose label says what it
does. The biggest panel drops from 1206px to 607px. The four subheads
that used to carry those names are gone, because the summary says it now.

Every panel name is also a heading. A <summary> is announced as a button,
so the sidebar's names — its whole navigation — were unreachable by
heading, and splitting Session would have removed the only four headings
down there. The outline is now h1 page, h2 per panel, h3 for the two
subheads that remain. Visually identical: the h2 gives up everything a
heading brings with it.

The cost is 216px more collapsed summaries at rest, three more one-line
labels. Audit unchanged at 2 failing configurations and 3 offenders, both
pre-existing. 795 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:52:34 -06:00
jheapsandClaude Opus 5 bd8ee11f77 Say who can win each pot in the fewest names that answer it
The pot breakdown listed every eligible player for every pot. Pot
eligibility is nested — the main pot is everyone still live and each side
pot above it drops whoever was all-in below — so the main pot's line was
every name at the table and each side pot repeated it minus one.

The lines too long to fit were exactly the ones nobody needed spelled
out, and the short ones at the bottom were the side pots a dealer
actually announces by name. Measured on an iPhone 14: a single all-in
five-handed already clipped the main pot, and twelve-handed the first row
wanted 521px of a 254px slot. There was no count and no title, so a
dealer reading "Alexandra…" could not tell whether three people or eleven
were eligible — the number the showdown turns on.

Each line now names whichever of "who is in" and "who is out" is shorter,
which nesting guarantees is never more than half the table, and leads
with the count because the line is right-aligned and it is the tail that
gets cut off. "all 9", "8 in, not Mike from work". On a phone this takes
one, two and three all-ins from every main-pot line clipped to nothing
clipped at all; twelve-handed goes 6 clipped rows to 5, and even a cut
row still opens with "9 in".

The wizard's copy of the same announcement wraps instead — it is a dialog
that scrolls, and it is what the dealer reads out before anybody shows a
hand — so nothing there is ever cut off. The full list is also on the
title for anyone with a pointer, which is the extra rather than the
answer, since a tooltip is not a mechanism a keyboard reaches.

potCalculator.test.js now proves the nesting across 20,000 random tables
and asserts on its own generator, so the loop cannot go vacuous. Audit
unchanged at 2 failing configurations and 3 offenders, both pre-existing.
787 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:23:19 -06:00
jheapsandClaude Opus 5 13d704301d Stand the felt up on a phone, and check which rule actually wins
A portrait phone got a square felt, so the vertical room the layout had
already measured was published and then dropped: 522px of room under a
366px table on an iPhone 14, 638 under 406 on a Pro Max. The cost was
seat names — 44px pucks with five of twelve clipped.

--feltAspect: 1.5 spends it. Twelve seats at 390x844 go from a 44px puck
with five names clipped to 66px with one; five seats go from three
clipped to none; the Pixel 7's twelve-seat ring goes to zero. 1.6 and
1.7 measure byte-identical, so 1.5 is where the sweep stops paying. The
three smaller phones are room-bound at any cap and measured identical
from 1.15 to 1.7.

The first version of this was a no-op. The rule went into
@media (max-width: 700px), and @media (max-width: 900px) below it set
the same property back to 1 — both match a phone, same specificity,
later one wins. It measured as 1 on every phone while reading perfectly.
The 900px rule is now a 701-900px range, and feltShape.test.js resolves
--feltAspect through the media queries the way the cascade does so the
next one of these is caught.

Also tried and removed: a floor under the max-height, so a tall bar
couldn't press the table flat. Twelve all in with nine side pots takes
the bar to 341px, leaving 142px of room at 360x640; floored to 242 the
felt keeps its shape by putting five of twelve seats behind the bar,
unfloored it is flat with all twelve visible. The slit it defended
against came from forcing the custom property by hand. A test asserts
its absence with the numbers.

Audit: 4 failing configurations and 5 offenders down to 2 and 3, both
remaining ones pre-existing. 776 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:02:34 -06:00
jheapsandClaude Opus 5 7bd205a91a Line the bar up with the table, and stop the button squeezing a name
Two things that were wrong at every desktop width, both of them the same shape: a
number that was right about one thing being used for another.

The action bar was centred on the window. The table is centred on its own column, and
the sidebar takes 320px out of the right of that — so the two things a dealer is
actually looking at sat 172px apart, at 960px wide and at 1600px and everywhere in
between, because the offset is half the sidebar and the sidebar is a constant. The bar
also finished 80px inside the sidebar's column at 1280, painting over whichever panel
happened to be at that height. It leans away from the sidebar now, by the sidebar's own
width, which comes out exact above and below the 1400px cap — measured on the running
page at four widths, 0px off at all of them. No 100vw in it, which is the point: the
bar's own centring comment warns that on a page with a scrollbar 100vw is wider than
the viewport, and the offsets are exact without it.

The dealer button's chip sits in the puck's top-right corner and the name kept out from
under it with `padding-right: 22px`. That takes the chip's width off *every* line of the
name, and the chip is on one of them. Measured at 1280x900: the name box is 78px,
"Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 and
broke as "Christop / her" — mid-word, no hyphen. The button moves every hand, so that is
a player whose name reads properly for four hands and comes apart on the fifth.

A float is what indents only the lines it sits beside. Line one gets 56px and the rest
get 78, so a name too long to fit beside the chip drops below it whole instead of being
broken to fit next to something that isn't there.

Not on a crowded ring, though, and finding that out cost a sweep. A tight puck gives the
name one line on purpose, and one line has nowhere to move to: with two lines granted
the name dropped onto a second line that the chip's lower half was still sitting on, and
the audit called it at every viewport in the list. So the float is for the ring with room
for two lines and the padding for the one without — two rules, matching two regimes the
puck already had.

The sweep also learned two things from this:

- Text is measured where it was painted, not where its box is. The seat-label check
  modelled "text kept out of somewhere" as padding, which is one way of doing it and not
  the only one; a float does it per line, and the check called every seat in the sweep an
  offender while the text sat exactly where it was meant to. Range rects ask the question
  directly, so padding, floats, centring, wrapping and a short label in a wide box all
  come out right without any of them being modelled.

- Offenders say how much is covered, not that something is. "Is under a badge" reads the
  same for a stack a dealer cannot read and for a badge's rounded corner grazing the last
  digit. Probing the whole line and counting turns the residual this sweep has carried for
  three rounds into "1/7 under SB" — one probe of seven, one configuration, thumb only.

294 audits, 4 failing configurations, 5 offenders: twelve players on a 360px phone with
the raise pad open (3/7 and 2/7, pucks genuinely overlapping — the search reports it
rather than papering over it) and the 1/7 hairline above. 770 unit tests.

Fault-injected, all restored and re-verified: writing the sidebar's width out again
instead of the token, growing the float to cover the chip's whole height, and taking the
padding off the crowded ring are each caught by a test; and with both the float and the
padding removed the rewritten label check still reports the name 6px under the chip,
which is the fault it exists for.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 18:17:16 -06:00
jheapsandClaude Opus 5 f07144acd8 Give the table the screen back when the phone is on its side
A phone held sideways is 640x360: short of height and swimming in width. The layout
spent the whole of that shortage on one column — 70px of header, 85px of felt, 205px
of action bar. Two rows of seat pucks 55px tall have to fit inside 85px of felt, which
they cannot, so seven of nine finished past the rail with up to 17px of puck floating
on the page. That single fault was 96 of the 98 offenders the layout audit could find
anywhere.

No amount of shrinking fixes it, because the shortage was never in the table. The bar
is 205px tall and 529 wide to hold three buttons and a status line, and it was taking
that height from the one element that needed height. So in landscape it moves: the bar
becomes a rail down the right-hand side, the table takes the column beside it, and the
felt goes from 529x85 to 409x205 — an actual oval, with room for two rows of pucks and
a gap between them. It also puts Fold, Call and Raise under the right thumb rather than
along the bottom edge, where a landscape grip has the palm.

Which layout the bar is in is declared by the stylesheet in a custom property and read
back by actionBar.js, because the one thing that must not exist is a second copy of
that media query written in JavaScript.

Then the ring stopped predicting itself. seatRingRadii works out the widest ring whose
outermost puck still finishes inside the rail, and it is right about a puck it measured
before the width search settled and before a name wrapped or was shortened — each worth
up to 15px of height arriving after the answer. Four viewports finished with the top
seat 5-6px over. render.js now checks the drawn result the way the audit judges it: if
the worst puck hangs 6px past the rail, the ring comes in by 6px and they are placed
again. A layout that predicts itself has to be right about the future; one that measures
itself only has to be right about the present.

Also in this round:

- Names shorten at a word instead of being chopped mid-one. "Mike from work" on a
  nine-handed phone was "Mike f…"; he is Mike. A name never ends on a joining word,
  because "Mike from" looks cut off rather than shortened, and the whole name is on the
  tooltip either way.

- The blind badge is counted in what a seat needs. It always points away from the table,
  which was taken to mean it costs nothing between two seats — true of a straight rail
  and false of a ring, where neighbours sit at different heights. Three tables had a
  player's stack painted under the next player's SB or BB, and that badge outranks
  everything else on the felt. It also got shorter, 15px to 12: clearing its own name at
  5px of overhang instead of 9 hands every chair in the ring four pixels back, which at
  640x360 is the difference between a 66px puck and a 48px one.

- Border-box, inherited, for the whole app rather than nine elements opting in one at a
  time. Both elements that hadn't went wrong in the same afternoon: the rail overran the
  page's padding by 25px and put Resolve Pot underneath itself, and the raise pad's
  amount field pushed a horizontal scrollbar across a 640px phone.

- The raise amount field is thumb-sized. The rule that sizes fields for a finger names
  the settings panels and the seat form, so the one field on the one surface built for
  thumbs was 36px on every phone in the list. The sweep never saw it because it had no
  step that opened the raise pad; it has one now, and that step also caught the rail
  squeezing its own status line under the pad.

- mobile-web-app-capable alongside Apple's older name, which Chrome has been calling
  deprecated in this page's console on every load.

The audit goes from 19 failing configurations and 98 offenders to 2 and 3, across 294
audits — thirteen viewports, three table shapes, a mouse and a thumb. What is left is
twelve players on a 360px phone with the raise pad open, where twelve pucks at the 44px
minimum genuinely do not fit on the felt; the search reports that rather than papering
over it. Each new mechanism was checked by breaking it on purpose: removing the settle
pass puts 26 offenders back, dropping the badge from the footprint 5, and un-sizing the
raise field is caught by the step that now opens it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 17:19:11 -06:00
jheaps fbc4bd9a3e Merge pull request 'Hold'em dealer: a felt that fits, and a table that reads its own showdown' (#118) from polish/holdem-seat-collisions into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #118
2026-09-04 15:28:46 -06:00
jheapsandClaude Opus 5 cd57c16050 Let the table tell the dealer who won
An option — off by default, and off means the tool is exactly what it was.

Turn it on and the community cards go on the felt. Each street asks for the cards as
it is dealt: the picker opens on the flop the moment the flop button is pressed, and
again on the turn and the river, because the moment a dealer is holding those cards is
the only moment they know what they are without turning the board round. Three taps,
then one, then one. The picker is the same sheet as the hole cards, which now knows
about the middle of the table too — the seven of clubs cannot be on the flop and in
somebody's hand, and until now it could.

Turning the players' cards over is unchanged. That already worked the way it should.

What is new is what happens next. Resolve Pot stops asking who won and says so:

    Alexandra wins $50 with three of a kind, nines.

with every hand at the table read out under its player's name, the winner already
ticked, and the button reading "Pay Alexandra". A chop comes out as "Christopher and
Mike from work split it, $22.50 each, with a pair of aces", both ticked. The dealer
still confirms — the tool reads the cards, a person still says the pot is good, and a
hand entered wrong is corrected by unticking rather than by starting over.

It declines rather than guesses. A board that isn't five cards, a player who hasn't
turned over, the same card entered in two places: each says which of those it is and
hands the question back, and the wizard is the one it always was. Who is *ahead* and
who *won* are different claims and only the second one can pay somebody.

Side pots are read per round, not once. The best hand takes the main pot and is then
out of the question; the side pot goes to the best of who is left, which is a different
answer and the one the tool has to ask for again.

handRank.js is the only module here that can pay the wrong person without anybody
noticing, so it is built to be checkable rather than clever: rank five cards, then take
the best of the twenty-one five-card hands in seven. Beside the written cases — every
category against every other, the wheel and the steel wheel, three kickers deep, the
counterfeited two pair, the board that plays — it is checked against a second evaluator
written a different way, on twenty thousand random seven-card hands and four thousand
random pairs of them. An ace that keeps its value in the wheel, a dropped two-pair
kicker and a flush read only by its top card were each introduced on purpose and each
caught.

The board takes the middle of the felt, sized from the room the seat ring actually
leaves — measured against every seat rather than the ring's widest point, which is only
the answer for a seat sitting exactly on the horizontal axis and had two pucks on top
of the board while claiming 77px of clearance either side. Where there is no middle to
take, it moves to a strip above the table: a phone on its side has a felt 528x83 with
55px pucks, so the ring closes over the centre entirely and a board in it is a board
with players painted on top.

That strip found an older bug. It costs the felt height, and a felt that changes size
had only ever been re-measured, never laid out again: the seats follow it on their own,
being placed in percentages, but a bet chip anchored inside a seat is placed in pixels
off that seat's centre. Measured at 640x360, the chips were positioned for a felt 83px
tall and painted on one of 60 — seat 3's $5 came to rest on seat 4's puck, and two
pairs of seat pucks were overlapping. The bar growing mid-hand does the same thing: a
side pot appearing is 55px off the felt. Both fixed.

The sweep grew a third table — the same five players in the same nine chairs as the
second, with the board on, so the only thing that differs between those two rows is the
board itself. It earned its place on the first run.

728 unit tests. 216 audits: what is left is the seat ring hanging past the rail on a
landscape phone, the known residual, plus five or six pixels more of it on the layouts
where the strip takes the felt's height, and a blind badge over a stack on a packed
twelve-seat portrait table.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 11:18:33 -06:00
jheapsandClaude Opus 5 2a080eab6a Give every bet on the felt somewhere to be
The chips were the last thing on the table nobody had measured, and the sweep had
never seen one: it audited a table that had never had a bet placed on it, so a third
of what is on the felt during a hand went unlooked at for the tool's whole life. It
posts blinds, deals and calls three times now before it measures anything.

What that found, and what this fixes:

- Chips accumulated. Seats are kept and updated in place rather than rebuilt, so
  clearing the bets layer stopped clearing the ones anchored inside a seat and
  nothing else ever did. An iPhone 14 had one chip per seat after the deal and five
  by the third render; a dealer's every tap left another disc on the felt.

- The ring-or-cling decision asked how wide the window was. A phone held sideways is
  844px across and gives the felt 117px: it passed the width test, took the ring, and
  every chip on it was painted under a puck — the seats carry z-index 1 and the bets
  layer carries none, so a chip that lands under one is gone, and with it the only
  number saying what that player has in. Measured now, and only measured.

- Getting clear of a box was `edgeReach(puck) + edgeReach(chip)`, which is right only
  straight up, down or sideways. Two axis-aligned boxes are clear when they are apart
  on one axis, so the travel is the reach of the box you get by adding their widths
  and their heights. At 768x1024 a $10 leaving a seat on the diagonal wanted 88px and
  got 77, and came to rest 11px inside its own player's puck — every seat off the four
  compass points, which is most of them.

- Every chip was laid out at the first chip's size. A chip is as wide as the number on
  it: "$5" is 21px and "$10" is 27px on a phone, so each $10 was placed as if it were
  6px narrower than it is. The widest is no better: at 768x1024 one 47px chip made the
  whole table give up a ring the other four had room for.

- Anchored chips settled where they landed. At 640x360 the felt is 528x83 and a puck
  is 55 of those pixels tall: clearing it takes 41px, the players facing each other are
  70px apart, and both chips set off 41px into a gap that is 70 wide. Three overlapping
  pairs on a twelve-handed table. Each chip now gives ground only to something it is
  actually standing on, a pixel at a time — a midline rule or a single scale factor
  both punish the seats at the ends of a rail, which were never going to hit anything.

- Nothing knew the pot total was there. Seat 1's $10 was sitting on "Pot $65".

Two things off the felt, from the same sweep:

- The toast outlived its news. "Next hand — button on seat 2. [Undo]" stayed up while
  the hand was dealt and three players called: an offer to move the button, wired to a
  snapshot that predates the hand, so taking it would have thrown the hand away without
  saying so. It was also in the way of thirty small screens' seats and controls. The
  bar drops it as it takes the next action; the bar's own Undo names what it would
  actually take back.

- The pot dialog cut a name off. Three players in the pot on a Galaxy S8, two rows
  visible, the third below a hairline that reads as the end of the list — a dealer
  ticking the two they could see would have paid two thirds of a three-way pot. The
  85vh budget was still subtracting the action bar's height from a dialog that has sat
  above the bar since its z-index was fixed, and a player's ceiling moves beside their
  name on a short screen. All three fit now, and where a list does run on, it runs on
  under a fade rather than stopping dead on a row boundary.

The pot badge goes on a phone held sideways: 86x66 of a 692x113 felt, for a number the
action bar prints three inches below it, standing in the only band a bet chip has.

The audit stopped calling one thing another. An element clipped away by a panel that
scrolls is not covered by whatever is painted at that height — the wizard's third
winner came back for four viewports as "fully covered by button Confirm Winner(s)".

190 audits: 25 failing configurations to 7, and every one of those seven is the seat
ring hanging past the rail on a landscape phone, which is the known residual from the
last round. 663 unit tests, 44 of them new.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 17:05:47 -06:00
jheapsandClaude Opus 5 f93e0935ef Give every seat its own pixels
Six players at a nine-handed table is the most ordinary table this tool draws, and on
every phone in the sweep one of them had their stack painted under the player next to
them. Measured across nine viewports and six table sizes: seventeen of fifty-four
tables hid a name or a stack behind something belonging to another seat.

Two causes, and the layout only knew about a third of one of them.

The felt decided how big a puck could be from a single number — the straight-line
distance between the closest two *neighbouring* seats, judged against a fraction of a
puck's width. Distance has one number in it and a puck is half again as wide as it is
tall, so the same 84px was generous side by side and nowhere near enough on the
diagonal. And neighbours are the wrong pairs to ask about: on a phone held sideways the
felt is 528x87, the ring is very nearly a straight line, and the seat that lands on top
of you is the one across the table. Ten of the tables in the sweep had a collision that
a neighbours-only check cannot see — seats 1 and 7, overlapping by 68px, while every
adjacent pair had room.

seatsCollide() asks the real question instead: do any two of these boxes want the same
pixels. Two equal axis-aligned boxes overlap exactly when their centres are closer than
one box in both axes, so it is an answer rather than a proxy, and it looks at every
pair.

The second cause was the small blind badge, sitting at `left: -6px` — hanging off its
own puck's left corner onto whatever was beside it. On an iPhone Pro Max at six seats,
with the pucks not touching at all, "SB" was painted across the next player's $1,137.
All three badges now sit inside the puck's width, and in the same place as each other:
a seat only ever wears one, so two positions bought nothing except making the dealer's
eye find which of two spots this seat was using before it could read which blind it is.

Then the layout had to be able to act on the answer, and it couldn't. It chose between
two widths — 96px or 58px — and if neither fitted it widened the ring and let the seats
overlap, on the reasoning that a dealer can see an overhang but cannot see a tap landing
on the wrong player. That trade is real; it was also a false choice, because the tables
that overlapped did not want either number. At nine seats a Galaxy S8 had room for a
61px puck, an iPhone 14 for 68, a Pixel 7 for 74, an iPhone Pro Max for 79. Three media
queries declared three more of these numbers — 78px, 64px, 62px — at three breakpoints,
guessing at something a media query cannot see: the same 390px phone has room for a 90px
puck at four seats and 61 at nine.

So the width is searched for now, not chosen. render.js walks candidates from the top,
applies each one, measures what the browser actually drew, and takes the widest that
leaves every seat its own box. There was a width that worked on every table in the
sweep; the code was never asking for it. Ten candidates is ten forced layouts, so the
result is cached against the felt's size, the seat count and the names — measured 12.5ms
cold, 0.8ms warm, and renderSeats runs after every tap.

Three things this got wrong on the way, all caught by measuring rather than reading:

  The badge overhang was folded into the space between two seats. That collapsed the
  ring on a phone held sideways — 87px of height, and 18px of badge is most of the room
  the ring has to move in — until a six-handed landscape table was unsatisfiable at any
  width. It didn't belong there: the badge points *away* from the table at both ends, so
  two seats facing each other across the felt wear them back to back.

  box-sizing: border-box, which the puck needed so the searched number means the width
  it is drawn at, quietly turned every min-height from a content height into a drawn one.
  An empty chair went from a 52px tap target to a 42px one on every phone in the sweep.
  The audit found it; the four min-heights have each gained the padding and border they
  used to be quoted without.

  The new audit check reported a name under a neighbour on a table whose seats were 9px
  apart. A puck's name is a `-webkit-box` with `line-clamp`, and getClientRects() hands
  back a rect for every line the text *would* have taken, including the ones the clamp
  throws away — so the probe was hit-testing 30px below the puck, where nothing is
  painted. It clips to what is painted now.

uiAudit gains "no seat covers another seat's writing", the complement of the check
already there for a puck's own decoration on its own name — which is why nothing caught
this: seatLabelsClear() only ever looked inside one seat. Fault-injected against the
previous version: eighteen configurations, naming both causes.

And the sweep itself now runs two table shapes. It had run one for its whole life —
five players at a nine-handed table — and how crowded the felt is depends on how many
people are sitting down far more than on anything a viewport can report. A full table
was never once looked at.

632 tests, 0 failures. 190 audits across thirteen viewports at two table shapes, with a
mouse and with a thumb. Every table from two seats to twelve now keeps its own text on
every viewport in the sweep. One configuration still reports: a dozen players on a
640x360 phone, where the felt is 87px tall and two rows of 55px pucks cannot both fit
and stay on the rail — the ring grows 8px past the edge to keep the seats off each
other, which is the trade the old code named and got right. Left reported rather than
tuned away.

Names on a crowded phone table truncate harder than they did, because the puck is now
sized to fit instead of allowed to overlap: "Christopher" is "Christ…" at nine seats on
an iPhone 14. The full name is in the DOM, in the seat's form and in the sidebar. The
next lever on that is the seat layer's inset, which is still a fixed 44px of "half a
puck" for a puck that is no longer 96px wide — worth 54px to 60px where it was measured.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 13:23:31 -06:00
jheaps 190ccebf6c Merge pull request 'Hold'em dealer: a reachable Confirm, a table that fits, offline, and a quieter first screen' (#117) from polish/holdem-offline-and-render into master
Build and Deploy / build-deploy (push) Successful in 1m42s
Reviewed-on: #117
2026-09-03 12:24:00 -06:00
jheapsandClaude Opus 5 c052ee32c8 Put the Confirm button back on screen
Opened the pot wizard — the one dialog in this tool that divides money — and its
Confirm button was below a scroll. Not on a tall table, not on one screen: at
every size I measured, with six players and a side pot.

  iPhone 14 390x844   244px of the dialog below the fold
  Galaxy S8 360x640   417px
  844x390 landscape   583px
  desktop 1280x900    172px

Nothing on screen said there was anything under the list of names, so a dealer
with the table waiting had to scroll a list of players to find the only control
that finishes the hand.

Two goes at this, and the first was wrong. A sticky footer is the obvious
answer and it cannot work here: the actions were the last child of the
scrolling content, so their containing block ended exactly where they did and
`bottom` had no room to lift them. Sticky on the *header* did work, for the
mirror-image reason — its container extends below it — which is what made the
failure confusing to read. Capping the player list wasn't enough either; the
pot breakdown and the log still overflowed on a short screen.

So the dialog is a proper column now: a title that doesn't move, a body that
scrolls, and a footer outside the scroll. potWizard.js appends its buttons to
the footer instead of into the body, and clears both together so a step can't
inherit the previous step's buttons. Measured after: Confirm on screen and
hit-testable at all six configurations, twelve-handed on a Galaxy S8 included,
and the dialog itself no longer scrolls at all.

The checkboxes are painted now, too. `accent-color` only ever coloured the tick
— the box around it stayed the platform's own bright white square, which on a
near-black dialog was the loudest thing on screen and the last raw control in a
tool where everything else had been drawn. Same surface ramp, same corner
radius, and a tick drawn with clip-path so it can't pick up a substituted font.

uiAudit gains "an open dialog's buttons are on screen", which hit-tests rather
than trusting geometry — a button can be inside the viewport and still be under
something. Fault-injected: putting the footer back inside the scrolling body
reports Confirm 105px below the fold.

Two things checked and left alone, because they turned out to be right: the
standings table hides three columns on narrow screens through a container query
keyed to the sidebar rather than the viewport, which is why a desktop shows
fewer columns than an iPad — the sidebar is the narrower box there. And In/Won/
Net read $0 for hands settled outside the wizard, which the comment above them
already explains.

Caught by my own guard on the way past: the token test read `scroll-padding` as
`padding` and failed the spacing scale. Correct by its rules, wrong in substance
— those values are a sticky bar's measured height, not a step in the rhythm — so
the lookbehind now rejects a hyphen as well as a word character. Re-fault-
injected to confirm it still catches a real ad-hoc gap.

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes and
35 across the five overlays. The whole wizard walked end to end: side pot
resolved, hand logged, $1,560 on the table before and after.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 12:20:02 -06:00
jheapsandClaude Opus 5 1f3dda180f Make the table big enough for the game around it
Looked at the felt at three times scale and the seats were falling off it. Not
sitting at a rail — hanging past the outer edge of the table into the page,
half a puck on green and half on nothing. Measured:

  1280x900, nine-handed   five of nine seats past the rail, worst 49px
  844x390,  six-handed    all six, worst 20px
  430x932,  six-handed    two, worst 12px
  360x640,  nine-handed   none

The ring sat at a flat 44% by 42% of the seat layer at every size, two numbers
picked against a phone. A fixed pair can only be right for one ratio of puck to
felt, and the felt's ratio changes with every viewport — so it was a different
amount of wrong on each one, which is exactly what makes something look like a
mistake rather than a choice.

The radius is derived now: the widest ring whose outermost seat still finishes
inside the table, worked out from the measured table, seat layer and puck. It
can only ever pull seats in — the old pair is kept as the ceiling — and it falls
back to that pair whenever there is nothing to measure, which is every call from
the unit tests and the first render before the felt is laid out.

Three things this turned up on the way, each caught by the sweep:

  * The seat layer is not centred on the table. Its insets differ top from
    bottom, so the first version — which assumed symmetry and worked from half
    widths — left a phone with 12px still over the rail. It works in absolute
    pixels against the nearer edge now.

  * The radius and the puck size depend on each other: the ring is limited by
    how big a puck is, the puck is sized by the `seatsTight` class, and that
    class was decided from the gaps between seats, measured at a radius that
    hadn't been worked out yet. Fixing the gap measurement then made a
    nine-handed phone overhang that had been fine, because the two were chasing
    each other. It is settled by trying the candidates in preference order
    instead: the roomy layout if everything fits, the tight one if not, and if
    neither fits, tight pucks on a ring widened only as far as it takes to stop
    seats colliding — because two seats fighting over a tap is a worse failure
    than a puck over the rail. A dealer can see an overhang; they cannot see
    that the seat they pressed wasn't the one that answered.

  * The spacing threshold was a flat 84px, the figure a 96px puck was judged
    against. Asking a 68px tight puck to keep a 96px puck's distance is a test
    it can never pass, so every landscape table fell through to the fallback and
    took an overhang it didn't need. It is a ratio now, not a number.

Result: worst overhang anywhere goes 49px to 9px, and 59 of 65 configurations
are completely clean. Desktop, tablet and every phone held upright: nothing over
the rail at all.

Residual, reported rather than tuned away: six configurations still overhang by
1-9px, all of them landscape at 640x360 and 667x375, where the felt is about
110-125px tall and no ring puts readable pucks inside that. The audit check is
left strict so they stay visible.

uiAudit gains "seats and chips stay on the table", which runs at every viewport
in the sweep. Fault-injected: putting the flat 44/42 back reproduces the original
failure exactly, including seat 3 at 49px.

Faster, too, because the measuring got hoisted out of the per-seat loops while I
was in there:

  renderSeats   7.3ms -> 1.8ms
  renderAll    12.4ms -> 6.8ms

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes.
Offline re-verified: a whole hand with the network cut, $800 conserved.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 11:55:51 -06:00
jheapsandClaude Opus 5 3ffce9d7c1 Stop the empty table shouting, and point at the next step
The first screen a dealer ever sees was the one nobody had looked at. Opened
fresh, with nothing saved, the tool showed:

  * the word EMPTY, in capitals, ten times across the felt
  * Resolve Pot as the accent-green primary — on a table with no players and no
    pot, so the loudest control on screen was the one that could do least
  * Next Hand, with no button to move
  * no indication anywhere of what to do first, except a grey caption under the
    felt saying "tap a seat to edit"

An empty state is the one screen a new user is guaranteed to meet, and this one
described itself ten times without once saying what to do.

**The felt stops repeating itself.** An open chair is already dashed instead of
solid, faded, and has no stack under it — that reads at a glance, which is how a
dealer looks at a felt, and it reads the same on the one open chair at a full
table, which is the case the label was actually for. The word is gone from all
ten, and the seat numbers underneath it are now the thing you see. What the word
carried for anyone reading by ear moves to an aria-label on the seat, which now
says more than the sighted version ever did: "Seat 3, empty", "Seat 1, Ann".

**The bar says what the table is.** It used to hide itself when nobody was
seated — silent at exactly the moment a dealer has never used this before. It
now says "Nobody's at the table yet." with one full-width button, Seat a player,
which opens the seat editor on the first free chair. That is the same panel
tapping a seat gives them, so the button teaches the gesture rather than
replacing it.

**Two dead controls stop pretending.** Resolve Pot and Next Hand are disabled
until somebody is at the table, which takes the accent green off the button that
cannot work and leaves the only green on screen on the one that can. Disabled
rather than hidden: hiding them would move the header the moment the first
player is named, and a control that vanishes teaches nothing about what it was
for. Next Hand's own explanatory title is kept and handed back when it
re-enables.

One bug of my own, found by walking the flow rather than by reading it: after
typing a name both header buttons stayed greyed out, still captioned "nobody is
at the table yet". commitSeatChange refreshes narrowly on purpose — a full
render on every keystroke takes the name field out from under the dealer
mid-word — and the comment directly above it already warns about this exact trap
for the action bar. The header needed the same line.

609 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes,
0 failures — the empty table is now one of those sizes, and had never been
audited before. Re-verified with the network cut: the first-run screen, the CTA
and seating a player all work offline.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 11:14:30 -06:00
jheapsandClaude Opus 5 5d5093f3cc Make the dealer tool work with no signal, and stop it shouting
Three findings this round, all measured on the running page.

**1. The tool needed the internet to exist.**

The night already survived anything — it lives in localStorage — but the page
did not. Reload in somebody's back room with one bar of signal and a dealer
holding a phone in one hand and a deck in the other got a browser error page
with nine players waiting. Verified: with no worker, an offline reload is
net::ERR_INTERNET_DISCONNECTED.

There is now a service worker (wwwroot/casino/sw.js) and a manifest, so the
tool installs to a home screen and runs with the network switched off. Measured
end to end: 19 assets cached from one ordinary visit, then with the context
offline the page loads, draws six seats, applies its CSS, and plays a whole
hand — blinds posted, four calls, $800 still on the table.

Three decisions worth writing down:

  * The precache list is read off the document rather than kept in the worker.
    Every stylesheet and script is already in the DOM; a hand-written file list
    stays right until somebody adds a script tag, and then the tool is quietly
    offline-broken until a dealer finds out at a table.
  * No skipWaiting. A new worker taking over a live page can hand it assets
    from a different release than the ones it started with, and this page loads
    seventeen scripts that expect to agree with each other.
  * Stale-while-revalidate for assets, not cache-first — see finding 3.

Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
prefix of the page it controls and "/casino/texasholdem" does not start with
"/casino/texasholdem/". Anything in scope the worker wasn't told about goes
straight to the network.

Program.cs needed .webmanifest added to the MIME map: an unknown extension is
not served with the wrong type, it is not served at all, and a 404 manifest is
exactly the silent failure that stops a browser ever offering "Add to Home
Screen".

**2. A screen reader read out the whole action bar after every tap.**

The bar carried aria-live on the element whose entire contents render() rebuilds
each action. Measured, after one tap:

  "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
   Fold Call $10 Raise"

Nothing in it is wrong; it is unusable at the speed a hand is dealt, and it is
the documented anti-pattern — a live region has to be a stable element whose
text changes, not a container that gets replaced. There is now a small
role="status" element that exists from page load and says one sentence:
"Dee to act, $10 to call. Pot $15." Guarded on the sentence rather than on the
state, because render() runs for many reasons that don't change whose turn it
is, and a live region handed the same words twice reads them twice.

**3. Every tap rebuilt ninety DOM nodes.**

renderSeats did `innerHTML = ""` and then cloned a template, attached three sets
of listeners and set a dozen attributes per chair — on every render, and
renderAll runs after every action. Profiled on a nine-handed table:

  renderSeats   7.3ms -> 4.0ms
  renderAll    12.4ms -> 9.5ms

The pucks are built once and updated in place, which is what the rest of that
function was already doing to their contents. A seat's node belongs to its index
for as long as the table is that size, so the listeners still close over the
index; only a change in the number of chairs rebuilds the row. Verified that
listeners don't stack: one click after six renders fires the hook exactly once,
and resizing 9 -> 4 -> 10 renumbers correctly.

Found along the way and deliberately left alone: ViewData["cssVersion"] renders
empty on every page of the site, so the ?v= cache-busting has never worked. It
is inert — Program.cs sends Cache-Control: no-store on all static files — and
the service worker was the only reason to care, which is why it uses
stale-while-revalidate instead. Noted in TexasHoldem.cshtml.

608 tests, 0 failures, including a new announcing.test.js. 52 layout audits
across 13 viewports x 4 table sizes: 0 failures, and the action bar still
doesn't change height mid-hand. Gestures re-verified after the seat refactor:
double-tap fold, long-press menu, give-the-button, and the active dealer chip.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 10:51:27 -06:00
jheaps aaf96543a8 Merge pull request 'Hold'em dealer: blind fix, design tokens, and the decrowd' (#116) from fix/naming-a-blind-deals-the-hand into master
Build and Deploy / build-deploy (push) Successful in 1m36s
Reviewed-on: #116
2026-09-03 10:26:50 -06:00
jheapsandClaude Opus 5 2e9c55af21 Give the felt back the screen the chrome was using
Measured on a 390x844 phone, six-handed, same table before and after:

                        before   after
  header                  193px    120px
  felt starts at          225px    152px
  controls on screen         27       18
  bordered elements          37       22
  text elements              61       49

A quarter of the screen went on chrome before the dealer saw the table.

**The header.** Six equally weighted buttons in two rows, of which two are
per-hand controls. The other four have gone where they belong rather than into
a "more" menu — that relocates crowding instead of removing it. Reset Table is
in the Session panel now, beside Clear history and the save/load pair: it is
the control that throws the night away and it was sitting in the same weight as
Resolve Pot, the most-pressed button in a session. Quick Split is down with
Common rulings, where the other "open something else" link already lived. The
way back to the site is a quiet link at the top, where a back link goes.

The subtitle is gone. It was one sentence of orientation on a tool used
nightly, and the landscape and short-phone rules already hid it — the layout
had decided it was expendable on every screen this is actually used on.

**Teaching copy, once.** Table Setup shipped open, so the first screen was the
most crowded one; it now opens only on a table nobody has set up yet, decided
once at load so it cannot reopen itself under a dealer who just shut it. The
gesture hint under the felt goes after the first deal of the session — keyed to
the hand number, not to bettingOpen, which was the obvious test and the wrong
one: it would have appeared and disappeared twice a hand, shunting the panel
stack up and down all night.

**Borders.** Panels had a border, a lighter background and a shadow — three
ways of drawing one edge. The standings had a rule under every row, which is
what makes a list read as ruled paper. Both drop the line and keep the
grouping; the standings header keeps its rule, because that one marks a change
of kind. Fields and buttons keep theirs: those say "interactive", which is not
decoration.

**The felt itself.** Names wrap to two lines instead of ellipsising, so
"Christopher" and "Mike from work" are readable rather than "Chri..." and "Mike
fro..." — the truncation was eating exactly the half a dealer typed to tell two
Mikes apart. And a stack of $1,234,567 rendered at 7px; it now drops the
thousands separators before it drops the type size, so the same number is eight
characters at 10px with every digit intact. The comment above fitAmount was
right that abbreviating loses precision — but precision the dealer cannot read
is not precision.

Two things tried and reverted, both measured:
  * Reserving the second name line made every puck the height of the tallest
    (69-73px became a uniform 88). Fifteen pixels on all nine seats is felt
    spent on chrome, which is what this round is trying to give back.
  * Dropping the name a type step to fit more on one line still did not fit
    "Christopher" on the button seat, where the dealer chip takes 22px of the
    row — so it cost legibility and bought nothing.

Known limit: a single word too long for a puck breaks across the two lines, and
gets a hyphen only where the browser has hyphenation dictionaries. Real phones
do; headless Chromium does not, so audit screenshots show "Christo / pher". The
whole name is visible either way, which it was not before.

599 tests, 0 failures. 52 layout audits across 13 viewports x 4 table sizes
(2, 6, 9, 12 seats) and 20 across the five overlays: 0 failures, and the action
bar still does not change height mid-hand at any of them. The back link was
caught by the audit at 56x13 when it stopped being a button; its hit area is
44px now, paid for with negative margins so the header keeps the 73px.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 10:17:08 -06:00
jheapsandClaude Opus 5 8b43614b1c Give the stylesheet a vocabulary, and stop the bar moving
The tool read as crowded on screens where nothing overlapped, and the reason
turned out to be arithmetic rather than taste: holdem.css used 42 different
spacing values, 31 font sizes, 14 border-radii and 139 colours, and defined
three custom properties. Every number had been picked by hand at the moment it
was needed. A 0.42rem gap beside a 0.45rem gap beside a 0.5rem gap gives the
eye no rhythm to lock onto, so it reads the whole surface as noise.

So: constrained scales, and everything snapped to them. Seven spacing steps on
an 8px rhythm, eight type sizes, four corners, and a four-step surface ramp
that stays off pure black — shadows are invisible on #000 and white-on-black
smears on the OLED phone this is used on. 661 values rewritten; colour mentions
halved, 381 to 174. Nothing here changes what the tool does.

Two things were measurably moving under the dealer's thumb, and both are fixed
by reserving space rather than by animating anything:

  * The undo pill named the last action, so it resized on every single tap.
    Measured across one hand: 148, 110, 109, 108, 109, 110, 120px. It is now a
    fixed 10rem, with the label in its own element so it can ellipsize without
    truncating through the keycap beside it.

  * The action bar is fixed to the bottom, so anything that changes its height
    moves its top edge. The status row wraps, and it wrapped differently as the
    street and pot changed. It now holds two lines' worth of room whenever a
    hand is running. Across 13 viewports x 3 table sizes, the bar no longer
    changes height mid-hand at all.

Numbers that change while the dealer watches — pot, stacks, bets, standings —
are now set in tabular figures, so a pot ticking $18 to $21 stops shuffling its
digits sideways.

Two regressions the sweeps caught on the way, both from the snapping:
  * The Add Chips field cleared 44px by way of its font size plus its padding,
    so moving either dropped it to 42. It states its own min-height now; a
    touch target shouldn't be an accident of two other numbers.
  * The bar came out 2px shorter in landscape, which grew the felt, which
    lifted the top seat's dealer button 2px into the header. That clearance was
    structurally 1-7px, so the top seat inset became a floor rather than a
    ceiling instead of chasing the 2px.

tests/holdem/designTokens.test.js keeps the scales honest — a new value has to
be a step or an exception with a reason. Fault-injected four ways.

599 tests, 0 failures. 39 layout audits across 13 viewports and 3 table sizes,
plus 35 across the five overlays: 0 failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 03:11:13 -06:00
jheapsandClaude Opus 5 42b9e83e8f Don't deal the hand when the dealer names a blind
The seat menu's "Make small blind" / "Make big blind" / "Blinds follow the
button again" all re-posted the blinds whenever `Betting.onlyBlindsIn(state)`
was true. That condition means "nothing has happened in this hand except the
blinds" — which is true of a hand one second old and equally true of a table
sitting in setup with no cards out and nothing committed.

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 11:11:34 -06:00
jheaps 419c1de2a0 Merge pull request 'Hold'em dealer: 55 polish rounds, one merge' (#101) from release/casino-holdem-polish into master
Build and Deploy / build-deploy (push) Successful in 1m32s
Reviewed-on: #101
2026-09-02 10:00:14 -06:00
jheaps 470fbc0d26 Add the seat-name round to the release 2026-09-02 09:52:35 -06:00
jheapsandClaude Opus 5 c4e842f3e3 Stop the button's chip sitting on top of the name underneath it
A phone screenshot, mid-hand, eight-handed: the seat with the button reads
"Christop" and then a white D. The name is centred across the whole puck and the
chip sits in its top-right corner on a layer above, so a long name runs
underneath and stops — measured, "Christopher" lost 20px of its 64px box.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:36:51 -06:00
jheaps 6174981cdc Add the assumed-contributions round to the release 2026-09-02 09:21:57 -06:00
jheapsandClaude Opus 5 4db6f6e4d3 Take the chips the pot was priced with, not just pay them out
Two rounds of asking where the tool disagrees with itself, and here is the last
of it — the one that was actually creating money.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:21:19 -06:00
jheaps 0ad5592687 Add the one-stack-rule round to the release 2026-09-02 09:06:07 -06:00
jheapsandClaude Opus 5 1c2e716508 Ask the same two questions the same way everywhere
The round before this found the bank check and the standings reading a seat's own
stack where the felt reads the table's starting stack — two parts of the tool
disagreeing about how much money was on the table. That was worth a sweep, and
the sweep found more.

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

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

Both questions now have one implementation and one name:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:23:27 -06:00
jheaps c36d7f60bd Add the cash-out round to the release 2026-09-02 08:09:26 -06:00
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
jheaps 15cec18256 Add the clock round to the release 2026-09-02 05:40:45 -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
jheaps 9c8d981e2d Add the house-take round to the release 2026-09-02 05:13:11 -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
jheaps 907e0165db Add the stadium ring round to the release 2026-09-02 04:59:37 -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
jheaps 86e1570618 Add the phone felt round to the release 2026-09-02 04:34:05 -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
jheaps 6df400322e Add the audit round to the release 2026-09-02 04:03: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
jheaps ca9ac7b6af Add the straddle round to the release 2026-09-02 03:32:58 -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
jheaps 80dae56f0d Add the landscape round to the release 2026-09-02 03:13:05 -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
jheaps 6ca99fde6b Bring master's already-merged rounds into the release branch
Master merged #58-#61 (manual deal, less guessing, seat editor, landscape
table) while the stack kept going. Those commits are already in this branch's
history; this merge just reconciles the two shapes so the release can go in as
one.
2026-09-02 01:19:56 -06:00
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
jheapsandClaude Opus 5 3149b48c14 Remember the pots the dealer throws away, so the bank check stops blaming them
Starting the next hand with money still in the middle destroys it. The tool asks
first, and says afterwards how much went. Nothing wrote it down, though, so when
the bank check came round at cash-out it saw only that chips were gone:

    $5 more cash than chips in play. Someone has paid for chips they haven't
    been given.

and settle-up told the dealer to fix it before anyone cashed out. Both were
accusing the dealer of a miscount for money the tool itself had removed, at the
dealer's instruction, minutes earlier — the one kind of shortfall that isn't a
mistake, reported as the one kind that is.

Discards are now recorded on the table as they happen (undoably, with the rest
of the Next Hand step), the reconciliation counts them alongside the chips, and
the panel shows a "Taken off the table" line so the number is visible rather
than merely subtracted. A genuine shortfall on top of a discard still reports,
at its true size — the discard accounts for its own $5 and not a cent more.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 20:27:46 -06:00
jheapsandClaude Opus 5 cb057b5159 Stop the think-clock claiming things it can't know
The clock beside the acting player told two lies, in opposite directions.

Refresh mid-hand and it restarts at 0:00. The clock lives in memory, so a reload wipes
it, and the bar then says the player has just started thinking when they might have
been in the tank for two minutes. It is the one number in the bar whose whole job is to
tell a dealer when to nudge somebody, and a refresh reset it to "no need".

It cannot know that time -- the action moved before this session existed -- so it does
not guess. It shows a dash until the action next moves, and says why if you hover:

  "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."

The moment the action moves it is measuring something it saw, and it counts from zero
again.

And past an hour it read "65:03". A table left open on a hand really does get there --
the state survives a refresh by design -- and sixty-five minutes written as though it
were minutes and seconds is a number the eye gets wrong. It reads 1:05:03 now.

Checked in the browser rather than reasoned about: dealt in this session it counts 0:00
then 0:02; refreshed mid-hand it shows the dash and keeps showing it rather than
drifting up from an invented zero; one action later it is back to a real 0:00; and with
the clock wound forward it reads 1:05:04 with the slow-tank styling still applied.

5 tests (283 total) on the formatting, including that minutes are padded once there are
hours and not before, and that a day reads 25:00:00 rather than wrapping.

Also checked and correct already: a backgrounded tab does not lose time -- four minutes
away reads 4:01 on return, because the clock is a timestamp difference rather than a
count of ticks -- and the timer disappears entirely once the hand is over.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 20:06:42 -06:00
jheapsandClaude Opus 5 ab92259e3e Put the dealer's own notes in the file they hand over
The CSV export carries the hands, the standings and the settle-up. It carried neither
of the two places a dealer types context.

Each seat can hold custom fields -- "IOU: $20 to Ann", "Seat rent: paid to 2am",
"Venmo: @ann" -- and the table has a notes box for the rest: side bets, a high-hand
promo, who owes the box from last week. Both are typed precisely because they matter to
settling up, and both were visible only inside the tool. The one place the context was
written down was the one place it could not leave.

Now the standings rows end with a Notes column carrying that seat's fields, and the
table's notes go in a section of their own at the end.

  Player,Paid In,Won,Net,Bought In,Chips Now,Up/Down,Notes
  Ann,20.00,60.00,40.00,200.00,150.00,-50.00,Seat rent: paid to 2am; Venmo: @ann
  Ben,20.00,0.00,-20.00,200.00,200.00,0.00,IOU: $20 to Ann
  Cal,20.00,0.00,-20.00,200.00,250.00,50.00,

  Table Notes
  "High-hand promo until midnight.
  Mo owes the box $15, from last week."

Notes go last so the money columns keep the shape a spreadsheet expects, and the
multi-line note is quoted as one cell rather than becoming rows that read as more
players.

An existing test caught this, correctly

"every money cell parses as a number" asserted that every column after the name was
numeric, and a Notes column is text by definition. The invariant it protects is real --
the file exists so somebody can total a column -- so it now names the six money columns
and checks those, rather than "everything after the first". That is the assertion it
always meant.

9 tests (278 total): a seat's fields ride along, the money columns keep their order, a
seat with nothing written gets an empty cell rather than the word undefined, a blank
field does not leave a stray colon, a multi-line note survives as one cell, no notes
means no section rather than an empty heading, and asking for the standings does not
change the seats.

Driven through the real Export CSV button, not just the function: the downloaded file
carries both seats' notes and the table's. Audit and contrast clean, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 19:52:20 -06:00
jheapsandClaude Opus 5 65f201cdfd Show each stack as the chips that make it
The chip legend has only ever read chips *into* an amount -- counting what is in front
of somebody. Colouring up at the end of a level is the same question backwards, and it
is the one the dealer answers with a rack in one hand: $205 is two hundreds and a five.
So is counting a stack out for a player who is leaving.

The arithmetic already existed. Payouts have been shown in chips since #73; this points
the same function at the stacks.

  Stacks in chips
    Ann — $205        2×$100, 1×$5
    Ben — $1,337      2×$500, 3×$100, 1×$25, 2×$5, 2×$1
    Cal — $40         1×$25, 3×$5
    Eve — $88         3×$25, 2×$5, 3×$1
    Fay — $500        1×$500

Sits under the chip legend, because that is where the denominations it works from are
set, and it only appears when there is both a legend to work from and somebody with
chips. A table that never set its chip colours up gets nothing, which is what it had
before. A seat on zero is left out rather than listed as nothing.

Driven at five sizes across touch and pointer contexts, with a 26-character name and a
$1,337,000 stack: no row spills, no page overflow, contrast clean, layout audit clean
but for the landscape phone already known from #63, no console errors. Every breakdown
checked by hand against its total -- 500+200+75+2 = 777 and so on.

The breakdown itself is covered by the twelve tests from #73; what is new here is which
seats are listed, which the browser run covers: with a legend and stacks it lists five
of six seats, with the legend emptied it disappears, and with every stack zeroed it
disappears. 269 tests still passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 19:36:29 -06:00
jheapsandClaude Opus 5 d297c1a2c9 Close two blind spots in the contrast check
Last change found a folded player's stack sitting at 2.22:1, hidden for twenty rounds
behind an exemption. So I went looking for the other exemptions rather than waiting for
the next one to surface. There were two, and neither is hiding a live fault today --
which is worth saying plainly, because it means this is a fix to the instrument and not
to the tool.

Leaf elements only

    if (el.children.length > 0) return;   // leaf text only

An element's own text stopped being measured the moment a child was added to it. The
undo control went that way two changes ago, the day a <kbd> was appended to it: its
label became invisible to this check with no sign that anything had changed. Now it
measures an element's own text nodes and leaves the descendants to their own turn,
against their own colours.

aria-hidden as a contrast exemption

The bets layer is aria-hidden because the amounts on the chips are already announced by
the seat and the action bar, and repeating them would be noise. That is a good reason
to hide them from a screen reader and no reason at all to stop checking whether a
sighted dealer can read them -- and the chips are exactly what the felt is read for.
Two different questions were being answered by one attribute.

Nothing is exempt now. If something genuinely decorative turns up, it should say so in
its own words rather than borrowing an attribute that means something else. The
watermark that the exemption was written for is gone as of the change before last.

What the widened check finds

Nothing. Zero offenders at six viewports across touch and pointer contexts, and with
the sidebar panels and the rulings sheet open. Measured from rendered pixels, the two
things that had been invisible are comfortable: the bet chip amounts read 12.65 to
13.74:1 and the undo label 8.06:1.

So the fix is proved by injection instead. Colouring the bet chip amounts #3a3a3a now
reports three offenders at 1.80:1, and colouring the undo control #2f2f2f reports it at
1.13:1. Neither was reported before.

269 tests still passing, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 19:21:06 -06:00
jheapsandClaude Opus 5 f0cfd24647 Make the seats that are out of the hand readable again
A folded player's stack measured 2.22:1.

The contrast audit has been reporting clean for twenty rounds, and it never looked at
these seats: the check skipped anything with opacity below 0.99 as "faded on purpose",
and folded and sat-out seats faded their whole box -- 0.5 and 0.58. Opacity on a box
fades the text you still have to read along with the box you wanted to push back.

Measured off rendered pixels, 11.5px text, where AA asks 4.5:1:

                  name    stack
  live            13.28   4.75
  folded           4.75   2.22
  sitting out      7.00   3.22

A folded player's stack is not decoration. It is what they will have next hand and the
answer to "how much has Ben got?", and at 2.22 it was barely there.

So the box recedes and the text does not: a flatter, darker background and a muted
border, with name and stack colours that measure.

                  name    stack
  live            13.28   4.75   (unchanged)
  folded           7.60   4.55
  sitting out      7.60   4.71

Taking the fade away took the signal with it, which the screenshot showed and the
numbers did not: a sat-out seat became almost indistinguishable from a live one. A
folded seat still says so twice, with a red border and a line through the name, but
sitting out had only faintness. It wears a dashed edge now -- the same thing an empty
chair wears, which is right, because neither is in this hand, and the name and stack
tell the two apart.

The same fault, found once the audit stopped hiding it

The key hints on the action bar are 9.6px and were measuring 2.97 to 3.33 against
their buttons -- a fade chosen for looks pushing text a dealer is meant to read under
the threshold, exactly like the seats. They are 4.93 to 7.63 now, still quieter than
the labels they sit beside.

And the audit no longer passes over faded text in silence. Something invisible is still
exempt, since there is nothing to read at zero opacity, but anything in between is
reported as unmeasured -- a number nobody checked is not the same as a number that is
fine. It now lists five: the four key caps and the disabled Export CSV button, which is
disabled and therefore genuinely exempt.

269 tests still passing. Contrast offenders zero, layout audit clean, no console
errors; the folded seat's red line-through and the sat-out seat's dashed edge both
confirmed in cropped screenshots rather than assumed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 19:10:06 -06:00
jheapsandClaude Opus 5 95fd46ee99 Take the broken wordmark off the felt
The felt carried "TEXAS HOLD'EM" across its middle, and the pot badge sat on top of
it. Measured at every seat count from two to ten, the middle 20% of the word was
covered, so the table read

  TEXAS  LD'EM

That is not a watermark, it is a rendering fault, and it looked like one.

I tried to move it before removing it. Four placements, measured across six viewports
and two seat counts each, as the share of the word covered by a seat, a bet chip or
the pot badge:

                        desktop   tablet   phone     phone     landscape
                        1280x900  768x1024 390x844   360x640   844x390
  centred (before)        20%      20%      20%       20%        20%
  30% down, full size      0-7%     0-12%   43-60%    48-70%     20-23%
  20% down, smaller        0%       0%      40-67%    53-70%     40%
  78% down, smaller        0%       0%      77-97%    90-100%*   27-37%

  * and off the oval entirely at 360px

Desktop and tablet can be made clean. A phone cannot: below 900px the felt is square,
and a square felt with six to nine seats has no clear horizontal band wide enough for
a word. The best phone result of any placement was 40% covered.

So it is gone rather than special-cased per device for a decoration. The felt still has
its gradient, its rim, the inner gold line and its shadow to be a table with, and the
page header says "Texas Hold'em Dealer" a few inches above it.

The reasoning and the numbers are left in the CSS where the element used to be, so
whoever thinks of adding one back finds out what happened first.

269 tests still passing. Seven viewports: contrast clean, layout audit clean everywhere
but the landscape phone already known from #63, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 18:54:52 -06:00
jheapsandClaude Opus 5 4cc8faf8cb Notice when the same table is open twice
Opening the tool in a second tab -- tapping the bookmark again rather than hunting for
the tab you already had -- silently throws away whatever the first one is doing.

Each tab holds its own copy in memory and each save writes the whole table, so the
second one to act wins. Driven with two tabs on one profile:

  tab A: deal, and Ann calls    stored -> bettingOpen true, committed [0,1,2,2]
  tab B: type a name in seat 5  stored -> bettingOpen false, committed [0,0,0,0]

The deal, the blinds and the call are gone, and neither tab said anything. That is the
same silent shape as the save and load failures either side of this change.

The browser already tells us: a storage event fires in every other tab when the key
changes, and never in the one that wrote. So a tab that sees its table move underneath
it now says so, once, with the way out attached:

  toast:  "This table is open in another tab. Whichever acts last wins."  [Reload]
  panel:  "This table is open in another tab, and both are writing to it — whichever
           acts last wins, so work done in the other one can vanish. Close one, then
           reload this tab to pick up the table as it stands."

Watched rather than merged, deliberately. Replacing the table under a dealer's hands
in the middle of a hand would be its own hazard, and reloading is the only resolution
that does not throw somebody's work away -- so it is offered rather than taken.

6 tests (269 total): a write from elsewhere is noticed, another app's key and our own
recovery key are not our business, ten writes produce one warning, watching twice
listens once, no listener is not an error, and no window at all is not an error.

Driven with two live tabs: both quiet at first, A deals and B warns with the reload
offer, A stays quiet about its own writes, two more actions in A add no further toasts
in B, and reloading B picks up A's table -- bettingOpen true, committed [2,1,2,2] --
with the warning cleared. Audit and contrast clean, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 18:35:27 -06:00
jheapsandClaude Opus 5 cfb83a2ba6 Don't quietly write over a save that couldn't be read
Last change made a failing save say so. This is the other direction, and it turned out
to be worse.

load() is well defended -- try/catch, a shape check, sensible fallbacks -- and every
one of those paths ends in `return this.createDefault()`. So an unreadable save becomes
an empty table. Driven with a save truncated to 70% of its length, the way an
interrupted write leaves one:

  1762 bytes stored, 1233 after truncating
  loaded: 0 named seats, hand #1 instead of #34
  toast: none.  warning: none.  anything on the page: no.

A dealer mid-session sees an empty felt and cannot tell whether they mis-tapped Reset,
whether the browser lost the night, or what.

And then the first edit destroys the evidence. I typed a name into a seat and storage
went from the 1233 unreadable bytes to 2549 bytes of fresh default state. Whatever a
person could have picked out of that JSON -- the names, the buy-ins, the standings --
was gone one tap after the tool decided it could not read it.

Now the unreadable value is moved aside to its own key before the fallback, and the
table says what happened:

  toast:  "The saved table couldn't be read, so this one started empty."
  panel:  "The saved table couldn't be read, so this one started empty. The
           unreadable copy has been kept rather than written over."

It is deliberately not offered as a restore. Half a JSON document is not a table, and
pretending otherwise would be worse than an empty one. It just stops being destroyed.

The two warnings share the Session panel line and both can show at once -- a browser
having trouble with storage tends to have it in both directions -- with the load
message first, because "why is my table empty" is the question the dealer is standing
there asking.

8 tests (263 total): nothing stored is a new table rather than a failure, a good save
loads clean, a truncated one is reported, valid JSON of the wrong shape counts too, the
copy is kept, a later save does not touch it, the listener fires once across three
reads, and storage with no room to keep the copy is not a crash -- which is exactly the
storage most likely to hand back half a value in the first place.

Driven through the UI: warning and toast on a corrupted save, the set-aside copy still
1233 bytes intact after editing a seat, and nothing shown at all on a healthy table.

One audit correction rides along. The collapsed-column check measured inside hidden
elements, so the standings header -- hidden with its table until somebody buys in --
was reported as three collapsed columns on every empty table. It skips hidden subtrees
now, and still catches a real one: squeezing a visible column reports the header and
all three rows.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 18:22:31 -06:00
jheapsandClaude Opus 5 4071be5329 Say so when the browser refuses to save
Every save goes through one try/catch, and the catch was empty:

    } catch {
        // Storage full/unavailable (private browsing, etc.) — the table still works
        // for the rest of the session, it just won't survive a refresh.
    }

The comment is accurate and the behaviour is right -- the chips have already moved in
memory and refusing to continue would be worse. What was missing is anybody being
told. Driven with setItem throwing: two saves failed, no toast, nothing on the page,
and a refresh came back to before the hand was dealt with the dealer none the wiser.

That is the same shape of fault the bank reconciliation exists for. Silent until it is
too late to act on, and the way you find out is the last two players cashing out.

Now it says so twice, in the two places that suit the two timescales.

A toast the first time, and only the first time -- a dealer taps this bar thirty times
a hand and does not need thirty warnings. Measured: four failed saves, one toast.

  "This table isn't being saved — refreshing would lose it. Keep this tab open."

And a standing line at the top of the Session panel, because a toast is a moment and
this is a fact about the rest of the night. The raise pad dismisses toasts, and the one
warning that must not be missed is not the one to leave to a toast.

  "This table isn't being saved — the browser refused. It'll keep working, but
   closing or refreshing this tab loses the session. Export CSV below keeps a copy."

Amber, like the bank check's disagreement, because both mean the same thing: your
record of the night is wrong and you can still do something about it. The export is
named because it is the thing that helps.

6 tests (255 total): a good save reports success and leaves no mark, a failing one
reports failure rather than throwing, the listener fires exactly once across twenty
failures, it is handed the error so the reason can be reported later, a failing save
still does not stop the table, and no listener at all is not an error.

Driven through the UI with setItem throwing: hidden while saving works, toast on the
first failure, zero further toasts across three more actions, the panel line present
and readable. Contrast clean, layout audit clean, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 18:08:05 -06:00
jheapsandClaude Opus 5 8a7d7b09bf Print the two shortcuts a dealer had no way to find
Seven keys drive this tool. Five of them are printed on the control they press -- F on
Fold, C on Call, R on Raise, the return arrow on Deal and on Raise to, esc on Cancel.
Two were printed on nothing at all:

  A   opens the amount pad on All-in
  U   takes back the last action

A dealer who learned F, C and R from the buttons had no way to discover the other two
existed. They are the two worth knowing: A is the fastest action in the tool and U is
the one that saves a misclick.

Both are on their controls now. The key lookup matches any of a button's classes, so
the raise pad's four quick amounts can share one class for their looks while only
All-in carries a key.

And a legend in the sidebar, listing all seven with what they do, built from the same
list the handlers are keyed off. There is a test that every key in the map has a line
in the legend and that every line names a key that really is bound -- a shortcut with
no line is one nobody can find, which is the whole bug this fixes.

The panel is hidden on a touch device. A phone has no keys to press, and it is the
same pointer question the touch-target rules ask rather than a guess about width.

One thing I broke and fixed on the way. .actionKey pins itself to the top-right corner
of a big action button. The undo control is a 30px pill with overflow hidden and no
positioning context, so the U escaped it and floated above the action bar -- visible in
the screenshot before I looked properly. It has its own inline class now, checked at
six sizes: the cap sits inside the pill and inside the bar, and the pill is not
clipped.

5 tests (249 total). Driven at six sizes across touch and pointer contexts: the undo
pill shows U, the raise pad shows A on All-in and nothing on Min, half pot and Pot, the
legend lists all seven, and it is display:none on a phone. Contrast clean, layout audit
clean but for the landscape phone already known from #63, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 17:52:52 -06:00
jheapsandClaude Opus 5 ebf22e3153 Shorten the rulings index to the words a dealer would say
Last change added an index to the rulings sheet and I wrote down what it cost: 342px
at the top of a 390px-wide sheet, so a dealer reading straight through scrolls past
the whole thing first. That was an honest trade at the time. It is also fixable.

The index was labelled with the section headings, which are sentences -- "A short
all-in doesn't reopen the betting" -- so nine of them stacked one per row. A dealer
hunting for that ruling is looking for the words "short all-in", not the sentence.

  Misdeal          Board card early    Out of turn
  Short all-in     Uncalled bet        Oversized chip
  Minimum raise    Odd chip            String bet

  phone       342px -> 143px   9 rows -> 3
  desktop      ~200 -> 70px            -> 2
  landscape          -> 94px           -> 2

The first ruling now starts 309px into the sheet on a phone rather than 440.

Each short label lives on its own section as data-short, next to the heading it
shortens, and the index falls back to the heading when there is not one -- so a ruling
added later still appears, still without anyone having to write it twice. The full
sentence stays as the button's aria-label, so the short label never costs a screen
reader the heading.

Checked at four sizes across touch and pointer contexts: nine labels, nine aria-labels,
every chip 44px on touch, jumping still lands and focuses the right heading, contrast
clean, no console errors. 244 tests still passing.

Also looked at, and left alone: the card picker refuses a card already dealt --
disabled, titled "Already dealt to Ann" -- and every one of its 52 buttons clears 44px.
A messy nine-seat table with 25/50 blinds, two folded players, a busted seat, accented
and hyphenated names, a $125,000 stack and a filled notes field audits clean at every
size but the landscape phone already known from #63.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 17:36:37 -06:00
jheapsandClaude Opus 5 f0994a8aed Get to the right ruling in one tap, and cover the string bet
The rulings sheet is opened mid-argument, with players waiting. It was eight rulings
of prose in a row with no way in. Reaching the last one meant scrolling:

  1.6 screens on a desktop
  2.1 on a phone
  3.5 on a phone held sideways

A dealer looking for the odd-chip rule had to read past seven others to find it.

There is an index across the top now, one chip per ruling, and tapping one scrolls its
section into view and puts the keyboard and a screen reader on its heading. Built from
the sections rather than written out beside them, so a ruling added later turns up in
the index without anyone having to remember to add it twice.

And the missing ruling

I went looking for what home games actually argue about rather than guessing, and the
answer that kept coming back alongside misdeals -- which were already here -- was the
string bet. It was the one common dispute the sheet had nothing to say about.

  Betting in one motion - the string bet

  A bet is whatever goes in on the first trip to the stack, unless the amount was
  said out loud first. Reaching back for more chips without having said "raise" is a
  string bet, and only the first motion stands. Saying the number is always enough on
  its own: "raise to sixty" binds whatever order the chips arrive in.

  It's the rule home games invoke most and enforce least, and both are fine -- the
  point is that a player can't watch the table react and then decide how much to bet.
  Say it, or push it all at once.

That second paragraph is deliberate. The sheet's whole stance is that house rules win,
so a ruling that tells a home game it must enforce something would be the wrong voice.

What it costs: the index is 342px at the top of a 390px-wide sheet, so a dealer who
wants to read straight through now scrolls past it first. That is the trade -- it is a
jump table you use once and then read, and the alternative was scrolling blind.

Driven through at four sizes across touch and pointer contexts: nine rulings, nine
index entries, every one of them 44px on touch, tapping the last one lands it on screen
and focused. Focus stays inside the sheet across fourteen tabs, Enter on a chip jumps,
Escape closes, and opening it three times still builds nine entries rather than
twenty-seven. Contrast clean, no console errors, 244 tests still passing.

Sources: cardplayerlifestyle.com and pokernews on string bets; the sheet still follows
the TDA rules it cites.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 17:21:40 -06:00
jheapsandClaude Opus 5 ddb3907ace Say the payout in chips, not just in money
"Ann - $135" is the answer to the arithmetic. The dealer's actual job at that moment
is working out that $135 is a black, a green and two reds, while four people watch
them do it.

The chip legend has known the denominations all along. It was only being used to read
chips *into* an amount -- counting a seat's stack -- and never to turn an amount back
into chips.

  Final Payouts
    Ann - $135
    1x$100, 1x$25, 2x$5

Largest first, which is greedy and so not optimal for an arbitrary set of values. Poker
chips are not arbitrary: every real set is canonical -- each denomination divides the
next, 1/5/25/100/500 -- and greedy is exactly optimal on those. A dealer who invents a
3-and-7 chip set still gets a valid answer, possibly one chip longer than it needed to
be, which is a fair trade for not solving a knapsack in a modal.

Two cases where saying nothing is the right answer, and one where it is not.

A table with no chip legend gets the amount on its own -- it has not told us its
denominations, so there is no advice to give. But a legend that exists and cannot reach
the amount does say so: "$3 no chip covers" when the smallest chip is $5. Half a dollar
in a dollar-chip game is the dealer's problem to solve and they can only solve it if
they are told, so it is never rounded away.

12 tests (244 total): the breakdown adds up to the amount across eight amounts, cent
chips make cent amounts exactly, a jumbled legend still pays largest first, the colour
comes along for a future caller, and the two no-advice cases are told apart from the
cannot-reach one.

Driven through a real all-in hand at four sizes: $135 reads "1x$100, 1x$25, 2x$5" and
a tournament-sized $1,350 reads "2x$500, 3x$100, 2x$25", fitting without spill down to
360px. With the legend emptied the line disappears and the payout stands alone.
Contrast clean, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 17:08:49 -06:00
jheapsandClaude Opus 5 3fe16fa7f2 Let a phone move a player to another seat
The hint under the table has said this on every screen since the gestures went in:

  Tap a seat to edit - double-tap to fold - press and hold for quick actions -
  drag to swap seats.

Three of those work on a phone. The fourth never has. Swapping seats is HTML5
drag-and-drop -- draggable="true", dragstart, drop -- and a touch screen does not
generate drag events at all. So the one clause written in the same breath as "tap"
and "press and hold" was the one thing a thumb could never do.

I found it by dragging seat 1 onto seat 4 with real touch pointer events and watching
nothing happen: no swap, no menu, no error.

Moving a seat is in the long-press menu now, which is the route a thumb already takes
for everything else. "Move to..." replaces the action list with the other chairs, by
number and name, and picking one makes the move:

  Move Ben to...
  Their chips, cards and bet go with them. The button and the blinds stay on the chair.
    Seat 1 - Ann     Seat 3 - Cal     Seat 4 - Dee     Seat 5 - empty ...

That subtitle is the rule, and it is the one a card room uses: the whole seat travels,
while everything naming a position stays put. Somebody who sits down in the big
blind's chair is the big blind.

Driven through on a phone: Ben, small blind, $109 behind with $1 posted, moves to
seat 5 with both numbers; seat 2 goes empty; the button stays on seat 1; the toast
reads "Ben moved to seat 5" and undo puts it all back. Reopening the menu on another
seat shows actions again rather than the chair list it closed on.

The hint tells the truth per device now -- dragging is only mentioned where a mouse
exists. Dragging still works there, checked.

8 tests (232 total): the whole seat moves, the bet moves with it, chips are conserved,
the button and the blinds stay on their chairs, swapping twice is a no-op, and a chair
that is not there is refused rather than swallowed.

One audit fix rides along. The "one dealer button" check counted the empty slot that a
hovered seat deliberately fades in, so it reported two buttons whenever a mouse
happened to rest on the table -- which is most of the time a mouse exists. It ignores
the hovered seat now, and still catches the real fault: with the pre-#62 behaviour put
back it reports six, or five while hovering.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:54:03 -06:00
jheapsandClaude Opus 5 f199148046 A pre-filled amount is a suggestion, so reaching for the box replaces it
Every amount box in this tool opens pre-filled: the raise pad offers the minimum, Add
chips offers a buy-in, the seat editor offers the stack a player already has. A dealer
taps one because they want a different number -- and a tap puts the caret after the
suggestion rather than over it.

Driven on an emulated phone:

  raise pad shows 4      tap, type "60"    -> 460
  add chips shows 200    tap, type "50"    -> 20050
  seat stack shows 200   tap, type "175"   -> 200175

The raise pad is the most time-critical control in the tool, and 460 is a number the
dealer never said. The all-in warning happens to catch that one against a $200 stack;
against a deeper stack it is simply accepted, and the pot is wrong from then on.

Number boxes now select what is in them when they take focus, so the first digit
typed replaces the suggestion.

  raise pad     4 -> "60"
  add chips   200 -> "50"
  seat stack  200 -> "175"
  a name      Cal -> "CalX"

That last line is the point of the rule. Only number boxes: type=number, or the
type=text/inputmode=decimal pair the raise pad uses to get a keypad without spinner
arrows. A name or a note is a thing you edit, and selecting all of it on focus would
be the same mistake pointing the other way.

Delegated from the document rather than wired per field. The raise pad, the add-chips
sheet and the seat editor all rebuild their inputs on every render, and a box added
later should not have to remember to ask for this.

The select is deferred a tick. Doing it inside the focus handler loses the race with
the browser placing its own caret, and the selection collapses again the moment the
tap completes.

10 tests (224 total) on the predicate, including the ones that must stay false --
name, note, colour swatch, checkbox, select, and an input with no type stated.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:40:04 -06:00
jheapsandClaude Opus 5 38536a5c0c Ask about the thumb, not the window width
There is a careful touch-target pass in this stylesheet -- 44px fields, 44px delete
buttons, a padded hit area for the rulings link. Every rule in it sits behind
`max-width: 700px`.

A phone held sideways is 844px wide.

So none of that work applied to the device it was written for, half the time. Measured
on an emulated touch device with the sidebar open -- twelve controls under 44px at
844x390 and again at 915x412:

  the rulings link            91x14
  the dedicated-dealer box    13x13
  chip-colour delete          28px wide
  blinds-direction select     32px tall
  five panel headers          38px

The rules are restated against `pointer: coarse`. The width-scoped block keeps its
layout changes -- one column, stacked dialogs -- because those really are about how
much room there is. This is only about what a thumb can hit, so it asks about the
thumb.

  844x390 touch   12 controls under 44px -> 1
  915x412 touch   12 -> 1
  390x844 touch    6 -> 1
  360x640 touch    6 -> 1

The one left is the bare 24x24 checkbox, whose row is 44px tall -- the row is the
target, which is the existing design and the only thing that works for a checkbox.

Two things were too small on every device, not just touch, and are fixed outright.
The panel headers -- the sidebar's entire navigation -- were 38px everywhere, phones
included; the pass below never reached them. And the rulings link was 91x14, which
misses the 24px minimum with a mouse as well as with a thumb. Under 24px anywhere is
now zero, at every size measured.

Two traps worth recording. A <summary> only draws its disclosure triangle while it is
display: list-item, so the headers get there on padding rather than a flex box. And a
<summary> is content-box, so `min-height: 44px` was *added* to the padding and gave
71px -- the same box-model trap as the header buttons one PR ago, in a different tag.
Both are stated in the CSS where the next person will hit them.

214 tests still passing; layout audit and contrast clean at six sizes across touch and
pointer contexts; no console errors; panels still toggle and the triangle is still
there.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:24:07 -06:00
jheapsandClaude Opus 5 286a924fe4 Give buttons and links the same box
The header on a portrait phone was 227px -- a quarter of the screen -- before the
table started, and at 360x640 the bottom of the table sat 16px behind the action bar
with the gesture hint off screen entirely.

Chasing where the pixels went turned up something better than a tight header.

.holdemBtn is worn by three <button>s and two <a>s. The class never stated a box
model, and the browser does not give those two tags the same one: Chromium makes a
button border-box and leaves a link content-box. So `min-height: 46px` on the header
row came out as 46px on the buttons and 46 + 16 padding + 2 border = 64px on the
links -- and a flex row is as tall as its tallest item, so all five became 64px.

A 46px touch target was costing 64px of screen, twice over, because the header wraps
to two rows on a phone.

  390x844   header 227 -> 191   buttons 64 -> 46
  412x915   header 227 -> 191
  430x932   header 227 -> 191
  360x640   header 173 -> 137   table clearance -16px -> +20px, so the felt now
                                clears the action bar instead of running under it

The other half is the one I did not go looking for. The 46px floor only exists under
the 700px breakpoint, and the accidental stretch was the only reason anything above
it was big enough to press. Once the boxes agree, a landscape phone -- wider than the
breakpoint, still a thumb -- would have been left with 42px buttons. So the 44px
floor is stated on the class itself. Measured across the page:

  844x390   13 .holdemBtn under 44px -> 0
  915x412   13 -> 0
  1280x900  13 -> 0

That is every button on the page, not just the header: the panel buttons, the pot
wizard, the confirm dialogs.

What it costs: 844x390 loses 2px of table clearance, -5 to -7, because its header
grows with the buttons. That size already has the bottom seat behind the bar (#63),
and 13 unpressable controls was the worse of the two problems.

214 tests still passing; layout audit and contrast clean at 9 sizes; no console
errors; every header button still hit-testable at 46x121 and 46x112.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:14:04 -06:00
jheapsandClaude Opus 5 3e9e2e899d Stop the big blind's own chip from covering the BB badge
On a phone the badge marking the big blind was not hard to read. It was not there.
The chip showing their $2 sat on top of it.

Both live on the same spoke. Bets sit between a seat and the pot, so on the bottom
half of the ring the chip comes down onto the seat's top edge, which is exactly where
the badge was -- and the chip is a layer above it.

  390x844, six seats:  badge 24x12 = 288px², chip covered 196px² of it
                       badge z-index 2, chip z-index 3
                       the badge's own centre hit-tested as the chip

So seats on the bottom of the ring wear their badge underneath instead, on the edge
the pot is not on. And badges now outrank chips regardless, so a layout this does not
anticipate cannot hide one again: which blind a seat is posting is the one thing on
that puck nothing else on the felt repeats, while a bet chip's amount is on the seat's
own total line as well.

Measured across 20 table sizes -- badge area lost to bet chips:

  430x932, 390x844, 360x640 at 2 and 6 seats    35% -> 0%
  8 seats                                        4-12%, unchanged: the blinds land
                                                 on the top arc there, where the
                                                 badge was already out of the way
  desktop and tablet                             0% either way, more room

Two other placements were measured and rejected. Tucking the badge inside the seat's
bottom edge put 38-42% of it on the seat's own stack line at every size including
desktop -- trading a chip covering the badge for the badge covering the money. Leaving
it on top and only raising its z-index worked everywhere, but leaves the badge sitting
on the chip rather than clear of it; the z-index is kept as a backstop, the move is
what does the work.

What it costs

At 360x640 heads-up the bottom seat's badge moves down into the action bar, where it
was visible before. That seat is already substantially behind the bar at that size --
the narrow layout reserves scroll room rather than capping the felt, which #63
measured and settled on -- but this change is what put the badge there, so I am
saying so rather than leaving it to be found. Every other size and seat count is
clean, including 360x640 at six and nine seats.

An instrument note, because it nearly became a finding. Bet chips are
pointer-events: none above 900px and auto below it, so elementFromPoint reports them
as covering things on a phone and as invisible on a desktop. The 35% figures above
are geometric overlap and a cropped screenshot of the rendered pixels, not hit tests.
Three "chip covered by DIV" results I saw earlier were that quirk and are not real.

214 tests still passing; layout audit and contrast clean at 8 sizes x 3 seat counts;
no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:00:34 -06:00
jheapsandClaude Opus 5 96cd7684f0 Buying chips puts you back in the game
Handing over cash and being dealt in are one event at a real table. Here they were
two, and the second one was easy to miss.

Last change deals a busted player around until they rebuy. So the dealer takes their
money, taps Add chips, and:

  Ben:  inHand false -> false     stack 0 -> 100     buyIns 100 -> 200
  toast: "Ben added $100."

He has chips, he is not in the game, and nothing on screen says so. He gets skipped
hand after hand until somebody notices and finds "Deal in" in the seat menu.

Now the buy-in does both, and says which:

  mid-hand:      "Ben added $100 - in from the next hand."   inHand true, folded true
  between hands: "Ben added $100 - dealt in."                inHand true, folded false

Folding him for the hand in progress is the rule "Deal in" already follows -- you
can't buy into a pot you have not been paying for -- so the two doors into the game
behave the same way. It is one undoable step: undo takes back the chips, the cash
record and the seat.

Not for a seat that was sat out while holding chips. That is a dealer who sat someone
out on purpose and is topping them up, and dealing them back in would be the tool
guessing at something it was not told. The test reads the seat's own recorded stack
rather than stackOf(), which substitutes the table's starting stack and would make a
seat with nothing recorded look like it was holding chips.

  busted, sat out          -> back in
  new chair, no stack yet  -> in
  sat out holding $100     -> stays out
  already in the hand      -> nothing to do, including an all-in at zero chips

8 tests (214 total).

Driven through the UI: the rebuy, the toast, the next hand actually dealing him in
(4 players, live 4), and undo putting all three fields back.

Found on the way, not fixed here

Buying in on a seat with no stack of its own credits the table's starting stack *plus*
the buy-in. A new player handed $100 on a table with a $100 default ends up with $200
of chips against $100 of recorded cash.

I am leaving it because the right answer is genuinely unclear and it is not mine to
pick. A blank stack means "no chips recorded" to the bank check, which counts it as
zero, and "the table's starting stack" to the betting code and the felt, which show
it as $100. Both are defensible for their own job, and which one a buy-in should
follow decides whether a casual dealer who never types stacks gets $200 or $100 after
a rebuy. Worth saying out loud rather than quietly picking one.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 13:39:56 -06:00
jheapsandClaude Opus 5 1af82dfddf Call a bet a bet and a raise a raise
The bar decided which word to use from what the acting player still owes. That gets
the big blind's option backwards -- they owe nothing, so it offered "Bet", but their
own blind is a live bet and putting more in is a raise. And it gets the other half
wrong too: open the pad to make a first bet on the flop and the confirm button said
"Raise to", then wrote "Ann's raise to $10" into the undo control for it.

  preflop, action back to the big blind
    was:  Fold | Check | Bet        pad: "Raise to"
    now:  Fold | Check | Raise      pad: "Raise to"
  flop, nobody has bet
    was:  Fold | Check | Bet        pad: "Raise to"   undo: "Ann's raise to $10"
    now:  Fold | Check | Bet        pad: "Bet"        undo: "Ann's bet of $10"

The arithmetic was always right -- minRaiseTo returns two big blinds for the big
blind's option and one for an opening bet, and I checked both before touching
anything. This is only what it is called. For a tool a dealer announces the action
from, that is not a small thing: a dealer who says "bet" when the big blind puts more
in gets corrected by the table.

The test is whether there is a live bet to raise, not whether this player owes
anything, and it is one helper rather than a condition written out five times.

The warnings follow the same word. Under-minimum with a bet in front of you leaves
you calling it; with an empty pot there is nothing to call:

  "$3 is under the $4 minimum. A legal raise is $4 or more -- otherwise it's a
   call, or all-in."
  "$1 is under the $2 minimum. A legal bet is $2 or more -- otherwise it's a
   check, or all-in."

Also the field's screen-reader label, which announced "Raise to amount" for a bet.

8 tests (206 total), including a table playing without blinds -- which opens with a
bet, and is the one preflop case where the old condition happened to be right.

Driven through the UI at seven viewports in both spots: every label correct, contrast
clean, no console errors.

One thing found and deliberately not touched. Fuzzing 40 hands with random actions
and rebuys -- chips stayed conserved to the cent throughout, no negative stacks, and
the acting seat was always a seat that could act -- turned up one disagreement:
isStreetComplete returns false during an all-in run-out where one player still has
chips and hasn't acted, while actingSeatIndex is correctly null. It has no symptom,
because the bar picks its layout from actingSeatIndex and shows "Deal Turn" as it
should. Changing a betting-round predicate with no observable fault behind it is a
worse trade than leaving it written down.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 13:23:02 -06:00
jheapsandClaude Opus 5 2cc2c66976 Deal around a player who busted and hasn't rebought
The most ordinary thing that happens in a home game: somebody stacks off, and they
don't buy back in that second. The table dealt them in anyway, and the result was a
hand nobody could finish.

Three players, two of them busted:

  bar before dealing:
    "Pot $0 | 3 players | All-in - running out the board (3 players)
     | Cal posts $1, Ann posts $2 | Post blinds & deal"
  after dealing:
    Cal posted nothing -- no chips to post with
    Ann posted $2 and was asked to check or bet against her own money
    liveSeats 3, isStreetComplete false, isHandOver false

Every line of that is wrong. It announced a board being run out over a felt with no
cards on it, promised a small blind from a seat that couldn't post one, took it from
nobody, and then sat on a hand with no way forward.

So a seat with no chips and nothing in the pot is dealt around. Their seat, name and
buy-in record are untouched -- sat out, not folded, because they didn't muck anything
-- and adding chips puts them straight back in the next hand.

  four players, Ben busted, button on Ann:
    "3 players | Cal posts $1, Dee posts $2
     | Ben has no chips - sitting out this hand. | Post blinds & deal"
    -> Ben inHand false, folded false; blinds exactly as promised; Ann to act

Said before it happens, for the same reason the button move is: a dealer shouldn't
find out from the felt. And the preview computes it on a view of the table rather
than by changing it, so the promise and the deal ask the same question -- the fault
these two lines exist to prevent.

When it can't be dealt at all, it says who has to buy in rather than pretending it's
a seating problem:

  "Only Ann has chips - Ben and Cal need to buy in."
  "Nobody at the table has chips left. Add a buy-in to deal."

Two smaller things fall out. The run-out banner is now gated on the betting actually
being open -- before the deal, a table of busted players is indistinguishable from a
table of all-ins to that test. And the bar's player count reads the table as it will
be dealt, so someone waiting on a rebuy isn't announced as one of the players.

Not the same as all-in, and the code says so: an all-in player has money riding on
the hand. A seat with no stack recorded isn't broke either -- those fall back to the
table's starting stack.

13 tests (198 total), and 5 of them fail with the sit-out and the run-out guard taken
back out.

Driven through the UI at seven viewports in both states: layout and contrast clean
everywhere but the landscape phone already known from #63, and no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 13:08:21 -06:00
jheapsandClaude Opus 5 a92a389245 Work out who pays whom
I went looking for what home-game dealers actually struggle with rather than reading
the code again, and the answer that kept coming up was the end of the night: hosts
reach for a separate app to finish a game "knowing exactly who owes whom in seconds
rather than minutes".

This tool already had every number that answer needs. The standings panel knows what
each player is up or down -- chips in front of them less the cash they handed over --
and then stops. Which leaves the dealer with six signed numbers at 2am and the actual
job still to do. That last step is the part people get wrong, not the poker.

  Ann  +$75    Dee  +$110      Fay -> Dee   $70
  Ben  -$10    Eve   -$40      Cal -> Dee   $40
  Cal  -$65    Fay   -$70      Cal -> Ann   $25
                               Eve -> Ann   $40
                               Ben -> Ann   $10

Largest debt against largest win, repeatedly. Greedy rather than optimal, which is
the right trade here: the true minimum number of transfers is NP-hard, while this
needs at most one payment fewer than there are people and each one it emits clears
somebody out of the list entirely. A dealer wants a short list to read down, not a
proof. Payers are the outer loop so everything one person owes prints together --
the list gets read out one player at a time.

All of it in integer cents. A settlement that has to come out exactly right is the
last place to let a float land on 41.99999999.

When the chips on the table don't match the cash taken

Then the payments can't all be made, and that is reported rather than quietly spread
across them. It is never rounding -- the arithmetic is integer cents -- so it is
always what the bank check sitting right above it is already warning about: a rebuy
nobody wrote down, or a miscount. Inventing a transfer to make the columns agree is
how a real person ends up short.

  $100 of winnings has nothing to pay it -- there are more chips on the table than
  cash was taken for. Fix that above before anyone cashes out.

The payment list goes into the CSV export too, under the standings it came from,
with the caveat if there is one. Whoever opens that file wants the answer, not to
redo the arithmetic in a spreadsheet.

13 tests (185 total): everyone ends up square, at most n-1 payments, a payer's lines
stay together, cents survive, nobody pays themselves, no payment is ever for zero,
both directions of an unbalanced bank, and one that settles a real table's standings
rather than hand-made numbers.

Driven through the UI at six viewports, balanced and not: clean layout audit and
contrast everywhere but the landscape phone already known from #63. Checked with
26-character names and seven-figure stacks on a 360px screen -- names wrap, amounts
stay whole and right-aligned, nothing overflows.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:52:56 -06:00
jheapsandClaude Opus 5 3d1ce1f45c Keep the table above the action bar on wide screens
The bar publishes its height so the layout can stay clear of it. Only the phone and
landscape rules ever read it. The wide layout sized the felt from its own width -- a
box 72% as tall as it is wide -- and so had no idea what was underneath it.

That was survivable while the bar was a constant. It isn't: it grows from 117px to
172px the moment a hand needs a side pot, which is exactly the hand where the dealer
most needs to see who is still in. Measured with two side pots, six seats, the bottom
seat sat this far behind the bar:

  1280x900    22px      1600x1000   54px
  768x1024    23px      1024x768    clear
  360x640     39px      390x844     clear

The first attempt at this didn't work and the failure is worth recording: the wrap was
a padding-bottom: 72% box, and percentage padding resolves against the containing
block's width rather than the element's own. Capping the width narrowed the felt from
822px to 746px and left it exactly 617px tall, so the seat stayed where it was. It is
an aspect-ratio box now, and the shape is stated once as --feltAspect so the height
and the cap both derive from it.

  1280x900   felt 857x617 -> 746x537   seat 22px behind the bar -> 51px clear
  1600x1000       ...760 -> 885x637              54px behind -> 59px clear
  768x1024   felt 689x689 -> 603x603              23px behind -> 56px clear

Six-seat tables at 1280x900, 1600x1000, 1024x768 and 768x1024 all go from one covered
seat to a clean audit.

Not applied below 700px. The narrow layout already had an answer -- it reserves the
bar's height as scroll room under the table, and a phone is a thing you scroll -- and
capping there was measured and rejected: at 360x640 it took the felt from 321px to
226px, which fixed the six-seat ring and broke the nine-seat one, two covered seats
traded for two seats whose centre landed on a neighbour.

Landscape phones opt out too. They set their own height from the bar already and are
sitting on their 7rem floor; 844x390 and 667x375 still have the bottom seat in the
bar, and nothing about a 112px-tall table with 73px seats can fix that.

The seat overhang is a constant rather than something measured off the layout it is
about to change -- a seat is ~73px tall at every width -- so this settles in one pass
instead of asking a different question each time it shrinks the felt.

172 tests still passing; no console errors; contrast clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:35:15 -06:00
jheapsandClaude Opus 5 29188d6049 Say ALL IN on the felt
A player with nothing behind had a blank where their stack goes. So did a seat whose
stack nobody had entered. On a table with side pots that is the one distinction the
dealer is working from -- who can still be bet at, and who is already in for
everything they have -- and the felt drew the two states identically.

  Ann  $40  $160     has chips
  Ben  $40           all-in for 40
  Cal  $40  $160     has chips
  Dee  $12           all-in for 12

The action bar knew: it had already built "Main pot $48, Ann/Ben/Cal/Dee" and
"Side pot 1 $84, Ann/Ben/Cal". The table it sits under didn't say a word.

Betting.isAllIn is the same stack test nextToAct uses to pass over a player, which is
the point: the seat the felt marks is exactly the seat the dealer will never be asked
to act on again. There's a test asserting that equivalence rather than restating the
condition.

The second half of the condition matters. A seat with an empty stack that has put
nothing in is a busted player who shouldn't have been dealt in, not a player who
moved all-in, so it isn't marked.

"ALL IN" in gold where the stack was, and a warmer border on the seat -- deliberately
quieter than the acting-seat ring, because all-in is a standing fact about a player
rather than a request for attention.

9 tests (172 total). They fail on a naive condition: dropping the committed-chips and
folded checks fails 4 of them.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:34:53 -06:00
jheapsandClaude Opus 5 4c1ac222a8 Give Enter and Space back to the control that has focus
Found while checking the keyboard could still reach the dealer button: it could
reach it and then couldn't press it.

Enter and Space were bound to "advance" for the whole document, with an exception
only for text fields. Those two keys are how a keyboard presses the control it is
sitting on, so every button on the page lost them -- and not to nothing. Measured:

  Tab to Resolve Pot, press Enter   ->  the table dealt the next hand
  Tab to Resolve Pot, press Space   ->  Resolve Pot, correctly
  Tab to a dealer button, Enter     ->  nothing at all

The inconsistency is the shortcut handler's return value. It calls preventDefault
only when it handled the key, so when "advance" applied it ran instead of the button,
and when it didn't apply the browser's own activation got through. A control that
does something else every other press is worse than one that does nothing.

Enter and Space now go to a focused button, [role=button], link or summary, and the
shortcut only claims them when nothing pressable has focus. The letters stay live
with a button focused -- a button does nothing with "f", and a dealer whose hand is
on the keys shouldn't lose them to a stray focus ring.

Space still advances the street: dealt, f, f, c, c, Space -> flop. The action bar
rebuilds itself after every press, so focus falls back to the body and the dealer's
Space is never quietly aimed at the Fold they just clicked.

11 tests (163 total), and they fail on the old handler -- 4 of them, exactly the
Enter/Space-on-a-control cases.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:16:15 -06:00
jheapsandClaude Opus 5 784315f2ea Put one dealer button on the table
A poker table has one dealer button. This one drew nine.

Every seat carried a puck: the real one in gold, and eight ghosts at 0.32 opacity
that existed to be pressed. Measured on a nine-seat table -- eight pale discs, all
eight showing the letter D, one of them the button. Finding it meant comparing nine
identical shapes for the one that's gold, when it should be the only thing of its
kind on the felt.

The ghosts also sat on their neighbours. Every remaining "something important is
covered" offender on a crowded table was a live seat covered by the seat-next-door's
ghost puck: 844x390, 915x412, 360x640 and 640x360 each reported one, and they were
all the same thing.

And a screen reader heard nine buttons named "D".

So a seat without the button doesn't draw one. Hovering a seat fades its slot in --
a preview of where the button would go, one click as before -- and on touch, where
there is no hover, "Give the button" has been in the seat's long-press menu all
along. Keyboard reaches it by focus, where the slot appears too.

pointer-events matters as much as the opacity: an invisible puck that still took
taps would be worse than a visible one.

  visible pucks    9 -> 1 at every size
  layout offenders 915x412 1->0, 360x640 1->0, 844x390 3->2, 640x360 3->2
  clean viewports  4 of 9 -> 6 of 9
  labels           nine "D" -> "Ann has the dealer button" and
                              "Give the dealer button to Ben"

The audit now checks it, because this is a rule about the table rather than about
CSS: one puck drawn, and no two announcing themselves the same way. Verified against
the old behaviour put back -- it reports 9 and 9.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:16:00 -06:00
jheaps b2262f8604 Merge pull request 'Wait to be told to deal, instead of guessing' (#58) from fix/casino-holdem-manual-deal into master
Build and Deploy / build-deploy (push) Successful in 1m10s
Reviewed-on: #58
2026-09-01 10:57:23 -06:00
jheaps 6ad9923aae Merge pull request 'Stop the last two things the table did behind the dealer's back' (#59) from fix/casino-holdem-less-guessing into fix/casino-holdem-manual-deal
Reviewed-on: #59
2026-09-01 10:57:17 -06:00
jheaps 168e8a380c Merge pull request 'Tapping a seat gives you that seat' (#60) from feature/casino-holdem-seat-editor into fix/casino-holdem-less-guessing
Reviewed-on: #60
2026-09-01 10:57:10 -06:00
jheaps edc08d7213 Merge pull request 'Let the landscape table use the width it has' (#61) from fix/casino-holdem-landscape-table into feature/casino-holdem-seat-editor
Reviewed-on: #61
2026-09-01 10:57:01 -06:00
99 changed files with 23690 additions and 1001 deletions
+3 -1
View File
@@ -366,4 +366,6 @@ MigrationBackup/
FodyWeavers.xsd
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
native/**/build/
native/**/build/
# Playwright MCP scratch output
.playwright-mcp/
+381 -43
View File
@@ -7,28 +7,58 @@
<div id="holdemApp">
<header id="holdemHeader">
@* Six equally-weighted buttons used to sit here in two rows, and on a 390px
phone the felt did not start until 225px down — a quarter of the screen spent
before the dealer saw the table. Only two of the six are per-hand controls.
The rest have gone where they belong rather than into a "more" menu, which
would have relocated the crowding rather than removed it: Reset Table down to
the sidebar with the save/load pair — in a panel called Save & reset, so the
danger is on the label rather than at the end of a scroll — Quick Split down
with Common rulings where the other "open something else" link already lives,
and the way back to the site up top where a back link goes. *@
@* The word is its own element so a phone can drop it and keep the arrow, and the
aria-label is what a screen reader gets either way. It contains the visible
text on the sizes that still show it, which is what WCAG 2.5.3 asks of a name
that says more than the label does. *@
<div id="holdemHeaderLeft">
<a href="/casino" class="holdemBackLink" aria-label="Back to Casino">←<span class="backLinkWord"> Casino</span></a>
<h1>Texas Hold'em Dealer</h1>
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
</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>
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
@* Not the accent any more. It is a shortcut to a sheet, available all night,
and it is never the next step — the bar always has that, and at showdown
the bar says so in as many words. See .actionAdvance in holdem.css. *@
<button id="resolvePotBtn" class="holdemBtn">Resolve Pot</button>
</div>
</header>
@* The seat sheet was the only one of the tool's seven overlays with nothing behind
it. On a phone it covers the felt from 250px down and the action bar with it, so
it is modal in everything but appearance — and what stayed lit above it was the
header, with Misdeal, Next Hand and Resolve Pot live under a thumb that was
reaching for a name field. NN/g and Material both say the same thing about this:
a modal sheet with an invisible scrim tells the user they can still touch what
they can still see, and they can't.
Not an `*Overlay` id, deliberately — Modal watches those and would take the
sidebar panel this becomes on a desktop for a dialog. It is driven entirely by
body.seatSheetOpen, and it is `display: none` above 700px where the panel is
just a panel. *@
<div id="seatSheetScrim"></div>
<div id="holdemLayout">
<section id="tableSection">
<div id="boardStrip" hidden></div>
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
<div id="tableCentre">
<div id="boardRow" hidden aria-label="Community cards"></div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
</div>
</div>
</div>
<div id="betsLayer" aria-hidden="true"></div>
@@ -37,6 +67,7 @@
<p id="tableHint" class="tableHint"></p>
<p class="tableHintActions">
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
<a href="/casino/texasholdem/quicksplit" class="linkBtn" title="Chop a pot without setting up a table">Quick Split</a>
</p>
</section>
@@ -52,8 +83,12 @@
</div>
</section>
<details class="sidebarPanel" open>
<summary>Table Setup</summary>
@* Not `open` in the markup any more. Whether this panel starts open is a
question about the table, so main.js answers it after the state has
loaded: open on a table nobody has set up yet, shut on one already
half-way through a night. *@
<details class="sidebarPanel" id="tableSetupPanel">
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
@@ -71,6 +106,22 @@
<input type="checkbox" id="dedicatedDealerToggle" />
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<label class="fieldRow">
<span>Ask me for the cards as they come out</span>
<input type="checkbox" id="trackBoardToggle" />
</label>
@* The label used to lead with "Track the board", which is the feature's name
rather than what pressing it does to your evening. What it does is put a
card sheet in front of you at four moments: each street, and the showdown. *@
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over &mdash; then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
<label class="fieldRow">
<span>Keep the screen on during a hand</span>
<input type="checkbox" id="keepScreenAwakeToggle" />
</label>
@* On by default, and the only setting here that is. A dealer has to
opt in to the tool doing more than they asked; they should not have
to opt in to it staying readable. *@
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks &mdash; and the tool says so the first time it takes hold.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
@@ -87,6 +138,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">
@@ -100,18 +177,32 @@
</div>
</details>
@* These four were one panel called "Session", which is a container word rather
than a name: it held the standings, a waiting list, the hand log and the
save/load/reset buttons — a topic, a task, a log and three utilities, which
is four kinds of thing under one label. It measured at 1206px and 103
elements, and "The table itself" was 1052px down inside it, so reaching
Reset table meant opening a panel called Session and scrolling past the
money and the log to get there.
Each panel is named for the thing a dealer came looking for, and the
subheads that used to do that job are gone, because the summary says it
now. They are in the order of a night: set the table up, watch the money,
fill the seats, look back at a hand — and, last and apart from the rest,
the two buttons that end it. *@
<details class="sidebarPanel">
<summary>Session</summary>
<summary><h2>Standings</h2> <span class="panelGlance" id="glanceStandings" hidden></span></summary>
<div class="panelBody">
<h3 class="panelSubhead">Standings</h3>
<p id="storageWarning" class="storageWarning" hidden></p>
<div class="standingsTableWrap">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<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>
@@ -127,11 +218,52 @@
<span>Chips in play</span>
<span id="bankChipsInPlay"></span>
</div>
<div class="bankReconcileRow" id="bankDiscardedRow" hidden>
<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>
<h3 class="panelSubhead">Hands</h3>
<div id="settleUp" hidden>
<h3 class="panelSubhead">Settle up</h3>
<ul id="settleUpList" class="settleUpList"></ul>
<p id="settleUpNote" class="fieldHint"></p>
</div>
</div>
</details>
<details class="sidebarPanel">
<summary><h2>Waiting list</h2> <span class="panelGlance" id="glanceWaiting" hidden></span></summary>
<div class="panelBody">
<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>
</details>
<details class="sidebarPanel">
<summary><h2>Hands</h2> <span class="panelGlance" id="glanceHands" hidden></span></summary>
<div class="panelBody">
<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">
@@ -142,20 +274,68 @@
</details>
<details class="sidebarPanel">
<summary>Chip Legend</summary>
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
<div class="panelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
<div id="colourUp" hidden>
<h3 class="panelSubhead">Stacks in chips</h3>
<dl id="colourUpList" class="colourUpList"></dl>
<p class="fieldHint">The fewest chips that make each stack — for colouring up, or counting one out.</p>
</div>
</div>
</details>
<details class="sidebarPanel" id="keyboardPanel">
<summary><h2>Keyboard</h2></summary>
<div class="panelBody">
<dl id="shortcutLegend" class="shortcutLegend"></dl>
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Table Notes</summary>
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
@* Last, and on its own, because everything in it is about the night as a whole
rather than the hand in front of you — and because two of the three buttons
are the ones you cannot take back.
Reset table came down from the header a while ago, where it sat in the same
weight as Resolve Pot: the most-pressed control in a session beside the one
that throws the night away. It used to be buried at the bottom of Session,
1052px down, which hid it but also meant nobody could find it on purpose.
Naming the panel for both jobs puts the danger on the label, where it is
read before the panel is opened rather than met at the end of a scroll. *@
<details class="sidebarPanel">
<summary><h2>Save &amp; reset</h2></summary>
<div class="panelBody">
<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>
@* That sentence has always been true, and always been missing its
second half: a browser is allowed to throw site data away when the
device runs short, and Safari does it on a timer. So the panel now
says which of the two situations this device is actually in, and
offers the one thing that changes it. See requestDurable() in
state.js for why the ask is a button rather than a page-load call. *@
<p class="fieldHint" id="storageDurabilityHint" hidden></p>
<button id="keepStorageBtn" class="holdemBtn holdemBtnSmall" hidden>Ask this browser to keep it</button>
<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 />
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
</div>
</div>
</details>
</aside>
</div>
</div>
@@ -163,9 +343,10 @@
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" title="Make this seat the dealer button">D</button>
<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>
@@ -173,6 +354,20 @@
</div>
</template>
@* Four things stacked flat used to be five: name, in/out, two hole-card slots, stack,
a five-box chip counter and a list of custom fields, every one of them at the same
weight, all of it open at once. Measured on a 390px phone the sheet came to 594px,
and the three controls a dealer touches on nearly every visit — the name, the two
status boxes, the stack — were 132px of it. The chip counter alone was 174px, the
tallest thing in the sheet, for a job most tables do once a night if at all; the two
card slots were 62px of dashed grey on every table that never asked to be shown a
card.
So the flat list is now three rows and three disclosures, and which of the three
stand open is a question about *this seat*, answered in renderSeatDisclosures: a
seat holding cards opens its cards, a seat with chips counted opens its count, a
seat with notes on it opens its notes. Anything with nothing in it stays a one-line
summary that says so. Nothing was removed and nothing moved to another screen. *@
<template id="seatEditorTemplate">
<div class="seatEditorForm">
<label class="fieldRow">
@@ -189,25 +384,45 @@
<span>Fold</span>
</label>
</div>
<div class="seatCards">
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
<details class="subPanel seatSubPanel cardsPanel">
<summary><span class="seatSubPanelName">Hole cards</span> <span class="seatGlance cardsGlance"></span></summary>
<div class="subPanelBody">
<div class="seatCards">
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
</div>
</div>
</details>
@* Named for the question it answers rather than for the boxes it contains. The
counter, the typed override and the total are one calculation — what this
seat has put in the pot — and the answer now sits on the summary, so a shut
panel still says it. *@
<details class="subPanel seatSubPanel contributionPanel">
<summary><span class="seatSubPanelName">What they've put in</span> <span class="seatGlance contributionGlance"></span></summary>
<div class="subPanelBody seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
</details>
<details class="subPanel seatSubPanel notesPanel">
<summary><span class="seatSubPanelName">Notes on this seat</span> <span class="seatGlance notesGlance"></span></summary>
<div class="subPanelBody">
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
</div>
</details>
</div>
</template>
@@ -219,9 +434,14 @@
</div>
</template>
@* The value used to be a `title` attribute, which is a tooltip — and a tooltip is
nothing at all on the device this is used on. Five coloured dots with five number
boxes and no way to tell which one was the $25 without opening the Chip Legend and
matching colours by eye. It is text now. *@
<template id="chipCountTemplate">
<label class="chipCountRow">
<span class="chipSwatch"></span>
<span class="chipCountValue"></span>
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
</label>
</template>
@@ -240,6 +460,7 @@
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsChips"></span>
<span class="standingsCashOut"></span>
</div>
</template>
@@ -288,7 +509,9 @@
not during it.
</p>
<section class="ruling">
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
<section class="ruling" data-short="Misdeal">
<h3>Redeal it — misdeal</h3>
<ul>
<li>The first card goes to the wrong seat.</li>
@@ -298,7 +521,7 @@
</ul>
</section>
<section class="ruling">
<section class="ruling" data-short="Board card early">
<h3>A board card turned too early</h3>
<p>
If the fourth board card gets turned before the betting round is finished,
@@ -307,7 +530,7 @@
</p>
</section>
<section class="ruling">
<section class="ruling" data-short="Out of turn">
<h3>Acting out of turn</h3>
<p>
A one-off is usually just backed up if no one has acted behind them.
@@ -315,7 +538,41 @@
</p>
</section>
<section class="ruling">
<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>
Somebody shoving in less than a full raise — say $3 more when the last
@@ -330,7 +587,7 @@
</p>
</section>
<section class="ruling">
<section class="ruling" data-short="Uncalled bet">
<h3>Chips nobody called come back</h3>
<p>
Bet more than anyone was able to call and the excess is returned to you
@@ -340,7 +597,7 @@
</p>
</section>
<section class="ruling">
<section class="ruling" data-short="Oversized chip">
<h3>One big chip, nothing said</h3>
<p>
The most common argument at a home table. A player facing a bet who
@@ -356,7 +613,7 @@
</p>
</section>
<section class="ruling">
<section class="ruling" data-short="Minimum raise">
<h3>A raise has to be a full raise</h3>
<p>
At least as much again as the last raise, or the big blind if there
@@ -367,7 +624,23 @@
</p>
</section>
<section class="ruling">
<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>
When a chopped pot won't divide evenly, the odd chip goes to the first
@@ -377,6 +650,22 @@
</p>
</section>
<section class="ruling" data-short="String bet">
<h3>Betting in one motion — the string bet</h3>
<p>
A bet is whatever goes in on the first trip to the stack, unless the
amount was said out loud first. Reaching back for more chips without
having said "raise" is a string bet, and only the first motion stands.
Saying the number is always enough on its own: "raise to sixty" binds
whatever order the chips arrive in.
</p>
<p>
It's the rule home games invoke most and enforce least, and both are
fine — the point is that a player can't watch the table react and then
decide how much to bet. Say it, or push it all at once.
</p>
</section>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
@@ -409,7 +698,17 @@
</div>
</div>
<div id="actionBar" aria-live="polite" hidden></div>
@* No aria-live here any more. This element's entire contents are rebuilt on every
action, so a live region on it announced the whole bar every time — measured, after
one tap: "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
Fold Call $10 Raise". That is what a screen reader read out after every single fold.
A live region has to be a stable element whose *text* changes; the one below is. *@
<div id="actionBar" hidden></div>
@* Whose turn it is, in a sentence. Empty in the markup and only ever written to, so
assistive tech is already watching it when the first update lands — a live region
created at the moment it has something to say is one the screen reader misses. *@
<div id="actionAnnouncer" role="status" aria-live="polite" class="visuallyHidden"></div>
<div id="toastHost" aria-live="polite"></div>
@@ -431,19 +730,58 @@
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
</div>
<div id="potWizardBody"></div>
@* The buttons live outside the scrolling body on purpose. This is the one
dialog that divides money, and its Confirm sat below a scroll at every screen
size — 244px off the bottom on an iPhone, 583 on a phone held sideways. A
sticky footer cannot fix that from inside: the actions were the last child of
the scrolling content, so their containing block ended exactly where they did
and `bottom` had no room to lift them. Out here they simply don't scroll. *@
<div id="potWizardFooter"></div>
</div>
</div>
@section Styles {
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
@* Installable, and works with no signal — see the service worker registration at the
foot of main.js. A dealer running a night in somebody's back room is on the worst
network there is, and the tool being a *website* was the last thing that could
take it away from them mid-game. *@
<link rel="manifest" href="/casino/holdem.webmanifest" />
@* iOS reads none of the manifest's icons and only this. *@
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
@* The standard name first, then Apple's older one. Chrome has been printing
"<meta name="apple-mobile-web-app-capable"> is deprecated" into this page's
console on every load; the replacement is the unprefixed name, and Safari still
wants the prefixed one, so both are here. *@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Hold'em" />
@* Paints the phone's chrome the same near-black as the felt's ground, so an
installed copy doesn't sit under a white status bar. *@
<meta name="theme-color" content="#141414" />
}
@* A note for whoever reads the ?v= below and assumes it works: it doesn't. ViewData
["cssVersion"] is assigned in _Layout's own code block, which runs after these
sections are handed their values, so every one of these renders as a bare "?v=" —
on this page and on Chess, Particles and Memory Lane too. It is inert rather than
broken: Program.cs sends `Cache-Control: no-store` on every static file, so nothing
is being browser-cached for a version string to bust in the first place.
Left alone deliberately. Fixing it is a one-line change to a file every page on the
site shares, and the service worker was the reason to care — that now uses stale-
while-revalidate, which needs no version to stay current. *@
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
@* After feedback.js, because the first lock it takes announces itself with a Toast. *@
<script src="~/js/casino/holdem/wakeLock.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handRank.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
+42
View File
@@ -1,6 +1,7 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.StaticFiles;
var builder = WebApplication.CreateBuilder(args);
@@ -52,8 +53,49 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
// One spelling of the casino URLs, because the Hold'em tool's offline support depends
// on it and nothing warns you when it breaks.
//
// Razor Pages routing is case-insensitive, so /Casino/TexasHoldem and
// /casino/texasholdem both serve the dealer tool and look identical. A service worker's
// scope is not: the spec matches it as a plain string prefix, so a worker scoped to
// "/casino/" does not control a page at "/Casino/TexasHoldem". Measured, that meant the
// capital-C spelling registered the worker, activated it, logged nothing — and then left
// the cache completely empty, because the page was never controlled and the precache
// handshake it waited on could not resolve. A dealer who bookmarked that URL had a tool
// that looked loaded and had nothing stored for the night the signal went.
//
// So one spelling wins and the other is redirected to it, permanently, before anything
// else in the pipeline gets a look.
//
// Deliberately scoped to /casino rather than applied site-wide. Every file under
// wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing a path
// here can never miss a real file on a case-sensitive filesystem — a guarantee that does
// not hold for the rest of the site, and one that /chessHub in particular would break.
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value;
if (path is not null
&& path.StartsWith("/casino", StringComparison.OrdinalIgnoreCase)
&& path.Any(char.IsUpper))
{
context.Response.Redirect(path.ToLowerInvariant() + context.Request.QueryString, permanent: true);
return;
}
await next();
});
// .webmanifest isn't in the default MIME map, and an unknown extension isn't served
// with the wrong type — it isn't served at all. Without this the Hold'em tool's
// manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser
// ever offering "Add to Home Screen": nothing is logged and no prompt appears.
var contentTypes = new FileExtensionContentTypeProvider();
contentTypes.Mappings[".webmanifest"] = "application/manifest+json";
app.UseStaticFiles(new StaticFileOptions
{
ContentTypeProvider = contentTypes,
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -0,0 +1,32 @@
{
"name": "Texas Hold'em Dealer",
"short_name": "Hold'em",
"description": "Seat your players, track the action, split the pot. Works with no signal.",
"start_url": "/casino/texasholdem",
"scope": "/casino/",
"display": "standalone",
"orientation": "any",
"background_color": "#141414",
"theme_color": "#141414",
"categories": ["utilities"],
"icons": [
{
"src": "/images/casino/holdem-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/casino/holdem-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/casino/holdem-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+125
View File
@@ -0,0 +1,125 @@
// Keeps the dealer tool working when the network doesn't.
//
// A home game is the worst network in the world: somebody's back room, one bar of
// signal, a router two floors away. The tool already keeps the whole night in
// localStorage, so the *table* survives anything — but the page didn't. Reload with no
// signal, or open it fresh when the wifi has dropped, and a dealer holding a phone in
// one hand and a deck in the other got a browser error page with nine players waiting.
//
// This is the one thing standing between "a website about poker" and "a tool you can
// rely on at a table", so it is worth the moving part.
//
// Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
// prefix of the page it controls and "/casino/texasholdem" does not start with
// "/casino/texasholdem/". Anything in that scope this worker has not been told about
// goes straight to the network, so the other casino pages behave exactly as they did.
// Bumped whenever the shape of what is cached changes. The asset *version* rides in
// the URLs themselves (?v=1.0.8 from _Layout), so a normal release makes new keys
// without touching this.
const CACHE = "holdem-shell-v1";
// What this worker will keep. Anything else in scope — a form post, an API call, a
// page it was never told about — goes straight to the network and behaves exactly as
// it did before the worker existed.
const CACHEABLE = new Set(["script", "style", "image", "font", "manifest"]);
// The page hands over the exact list of URLs it was built from — see registerWorker()
// in main.js. Deriving it from the document instead of keeping a list here is the
// whole trick: a hand-written manifest of files drifts from the markup the first time
// somebody adds a script tag, and the failure mode is a tool that is offline-capable
// right up until the release that quietly broke it.
async function precache(urls) {
const cache = await caches.open(CACHE);
// Individually rather than cache.addAll, which rejects the whole batch if any one
// URL 404s — one stale entry in the list would leave the dealer with nothing.
await Promise.all(urls.map(async url => {
try {
const res = await fetch(url, { cache: "reload" });
if (res.ok) await cache.put(url, res);
} catch {
// Offline while installing. The runtime handler below picks these up on
// the next online visit.
}
}));
}
// No install handler, and deliberately no skipWaiting(). A new worker taking over a
// live page can hand it assets from a different release than the ones it started with,
// and this page loads seventeen separate scripts that expect to agree with each other.
// Waiting costs a dealer nothing — the update lands the next time they open the tool —
// and it cannot leave them with a half-swapped app in the middle of a hand.
self.addEventListener("activate", event => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys
.filter(key => key.startsWith("holdem-shell-") && key !== CACHE)
.map(key => caches.delete(key)));
await self.clients.claim();
})());
});
self.addEventListener("message", event => {
if (event.data && event.data.type === "precache" && Array.isArray(event.data.urls)) {
event.waitUntil(precache(event.data.urls));
}
});
// Only GETs, and only same-origin. A cross-origin request has opaque responses that
// can't be inspected, and caching one would mean serving a failure forever.
function handles(request) {
if (request.method !== "GET") return false;
const url = new URL(request.url);
return url.origin === self.location.origin;
}
self.addEventListener("fetch", event => {
const { request } = event;
if (!handles(request)) return;
// The page itself: network first, so an online dealer always gets the current
// build, and the cached copy is there the moment they aren't.
if (request.mode === "navigate") {
event.respondWith((async () => {
try {
const fresh = await fetch(request);
if (fresh.ok) (await caches.open(CACHE)).put(request, fresh.clone());
return fresh;
} catch {
const cached = await caches.match(request, { ignoreSearch: true });
if (cached) return cached;
throw new Error("offline and this page was never cached");
}
})());
return;
}
// Assets: stale while revalidate. The cached copy answers immediately — which is
// the whole point, a dealer opening this between hands should not wait for a
// network — and a fresh copy is fetched in the background for next time.
//
// Cache-first would be the obvious choice, on the reasoning that these URLs carry
// ?v= from the layout and so a hit is always the right file. They don't: the
// version renders empty (see the note in TexasHoldem.cshtml), so every release
// reuses the same URLs. Cache-first against unversioned URLs is how a tool gets
// frozen on a build from three months ago with no way for anyone to tell. This
// strategy needs no version to be correct: worst case a dealer is one launch
// behind, which for a tool that runs entirely in the browser is nothing.
if (!CACHEABLE.has(request.destination)) return;
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const cached = await cache.match(request);
const fresh = fetch(request).then(res => {
if (res.ok) cache.put(request, res.clone());
return res;
}).catch(() => null);
if (cached) return cached;
const res = await fresh;
if (res) return res;
throw new Error("offline and not cached: " + request.url);
})());
});
File diff suppressed because it is too large Load Diff
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because it is too large Load Diff
+439 -13
View File
@@ -13,6 +13,15 @@
const Betting = {
STREETS: ["preflop", "flop", "turn", "river"],
// How many community cards are face up once a street has been dealt. The flop is
// three at once and everything after it is one at a time, which is why this is a
// table rather than an index.
BOARD_CARDS: { preflop: 0, flop: 3, turn: 4, river: 5 },
boardCardsFor(street) {
return this.BOARD_CARDS[street] ?? 0;
},
streetLabel(street) {
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
},
@@ -32,28 +41,159 @@ const Betting = {
// Who would post if the hand were dealt right now. Used to preview the blinds
// while the table is still being set, so a dealer can see where they'll land
// before any money moves.
// A seat that is dealt in, has nothing in the pot, and has no chips behind.
//
// Not the same as all-in: an all-in player has money riding on the hand. This is
// somebody who busted and hasn't bought back in yet, which a home game produces
// about once an hour. A seat with no stack *recorded* isn't broke either —
// stackOf falls back to the table's starting stack for those — only an explicit
// zero counts.
brokeSeats(state) {
const broke = [];
state.seats.forEach((player, index) => {
if (!player.inHand) return;
if (this.handTotal(player) > this.EPSILON_CHIPS) return;
if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
broke.push(index);
});
return broke;
},
// The table as it will be once the seats that can't post are dealt around. A view,
// not a change: the preview has to ask the same question the deal will answer, and
// asking it of a different shape of table is how the two came apart last time.
tableAsDealt(state) {
const broke = this.brokeSeats(state);
if (broke.length === 0) return state;
const skip = new Set(broke);
return {
...state,
seats: state.seats.map((player, index) =>
skip.has(index) ? { ...player, inHand: false, folded: false } : player),
};
},
blindPreview(state) {
// Dealing relocates a button parked on an empty chair before it works out who
// posts, so the preview has to answer for the button where it *will* be. Asking
// from where it currently sits gave a different pair of blinds than the deal
// produced — the preview promising one thing and doing another is exactly the
// guessing this whole step exists to remove.
const buttonIndex = HoldemState.buttonSeatForDeal(state);
const asDealt = buttonIndex === state.dealerSeatIndex
? state
: { ...state, dealerSeatIndex: buttonIndex };
// guessing this whole step exists to remove. Same reason the broke seats come
// out first: the preview once promised a small blind from a seat with no chips.
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
if (sbIndex === null || bbIndex === null) return null;
const satOut = this.brokeSeats(state);
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
// their chips and stayed sat out, silently skipped hand after hand until somebody
// noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
// read "Ben added $100".
//
// Not for a seat that was sat out while *holding* chips. That's a dealer who sat
// somebody out on purpose and is topping them up, and dealing them back in would be
// the tool guessing at something it wasn't told. Only a seat with no stack of its
// own: busted at zero, or a chair being bought into for the first time.
//
// Deliberately reads player.stack rather than stackOf(), which substitutes the
// table's starting stack for a seat that has none — the question here is what this
// seat was actually recorded as holding.
buyInDealsThemIn(player) {
if (!player || player.inHand) return false;
const own = player.stack;
const recorded = own !== null && own !== undefined && String(own).trim() !== "";
return !recorded || Number(own) <= this.EPSILON_CHIPS;
},
// "bet" or "raise" — what putting more in is actually called right now.
//
// A wager into a pot nobody has wagered into is a bet; everything after that is a
// raise. The bar decided this from what the acting player still owes, which gets
// the big blind's option backwards: they owe nothing, so it offered "Bet", but
// their own blind is a live bet and putting more in is a raise. It gets the other
// half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
// and wrote "Ann's raise to $10" into the undo control for it.
//
// The arithmetic was always right (minRaiseTo returns two big blinds for the big
// blind's option and one for an opening bet). This is only what it's called, which
// for a tool a dealer announces the action from is not a small thing.
wagerVerb(state) {
return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
},
committed(player) {
return this.round(player.committed || 0);
},
@@ -117,6 +257,20 @@ const Betting = {
return null;
},
// A player still in the hand with chips in the pot and nothing left behind. This is
// the same stack test nextToAct uses to pass over them, which is the point: the
// seat the felt marks all-in is exactly the seat the dealer will never be asked to
// act on again.
//
// The "chips in the pot" half matters. A seat with an empty stack who hasn't put
// anything in is a busted player who shouldn't have been dealt in, not a player
// who moved all-in, and calling them all-in on the felt would be a lie.
isAllIn(state, player) {
if (!player || !player.inHand || player.folded) return false;
return this.handTotal(player) > this.EPSILON_CHIPS &&
this.stackOf(state, player) <= this.EPSILON_CHIPS;
},
// Which chair sits `step` places along in the direction of play. The table owns
// this — walking the seats is exactly where the button, the blinds and the action
// used to drift apart from each other, so there is one implementation of it.
@@ -182,6 +336,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);
@@ -243,6 +402,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";
@@ -289,6 +452,11 @@ const Betting = {
// True when the hand is still live but nobody can bet again — the dealer is just
// running the board out.
isRunningOut(state) {
// Only while a hand is actually being played. Before the deal, a table of
// busted players who haven't rebought looks exactly like a table of all-ins to
// this test, and the bar was announcing a run-out over a felt with no cards and
// no chips on it.
if (!state.bettingOpen) return false;
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
},
@@ -300,6 +468,116 @@ const Betting = {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
// Are the blinds actually down? Moving the button or naming a blind means two
// completely different things depending on the answer, and `onlyBlindsIn` alone
// cannot tell them apart: a table sitting in setup, with no cards out and nothing
// committed, satisfies it just as well as a hand that has only just been dealt.
//
// With a hand open, the blinds are chips on the felt. Re-aiming them has to take
// those chips back and post them again from the new positions, or the badges and
// the money disagree.
//
// Before the deal it is a marker being slid round a table with nothing in the
// middle. Re-posting there would *deal the hand* — betting opened, blinds taken off
// two stacks, action on the seat after the big blind — which is not what a dealer
// means by "she's the big blind this one". Setting the positions is how a dealer
// gets ready to deal; the cards come out when they press the button, and not a
// moment before.
blindsArePosted(state) {
return !!state.bettingOpen && this.onlyBlindsIn(state);
},
// Chips the pot has been priced with but nobody has actually paid.
//
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
// bet leaves the stack, sits in front of the player, and is swept into their hand
// total at the end of the street. The other way is the "without live betting" panel
// — a base pot amount, or chips counted out on a seat — where the dealer is
// recording a hand they are watching rather than driving, and nothing ever leaves
// anybody's stack.
//
// The pot wizard prices both the same way, and awarding it credits the winner. So
// in that second mode the table simply grew: measured at a three-handed table with
// a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
// left the bank check blaming a rebuy that never happened.
//
// So before the pot is pushed, whatever was assumed rather than taken comes off the
// stacks that assumed it. Chips already committed or swept are left alone; they
// have been paid once already, and taking them twice is the same bug the other way
// round.
collectAssumedContributions(state) {
const taken = [];
state.seats.forEach((player, index) => {
const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
const owed = this.round(priced - alreadyPaid);
if (owed <= this.EPSILON_CHIPS) return;
// Never more than they are holding. A seat priced above its own stack is a
// dealer's typo or a player who is short, and inventing the difference is
// what this whole function exists to stop.
const moved = this.round(Math.min(owed, this.stackOf(state, player)));
if (moved <= this.EPSILON_CHIPS) return;
player.stack = this.round(this.stackOf(state, player) - moved);
// Recorded where a swept bet lives, so the money is in exactly the place
// the rest of the tool looks for chips that are in the middle this hand.
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
});
return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
},
// A misdeal: the hand never happened, so every chip goes back where it came from.
//
// The rulings sheet in this tool has told dealers to redeal since the day it was
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one
// of the first two cards exposed — and the tool had no way to do it. The only
// control that ended a live hand was Next Hand, which says out loud that it takes
// the money off the table without paying anybody. That is right for an abandoned
// pot and wrong for a misdeal, where the blinds and any calls belong to the people
// who put them in.
//
// Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
// while the action history is still there. It lives in memory, so a dealer who
// refreshes the page mid-hand, or who is three calls in, has nothing.
//
// What is deliberately kept: the button, the blinds, the straddle and anyone
// posting in. A misdeal is the *same* hand dealt again, not the next one.
takeHandBack(state) {
const returned = [];
state.seats.forEach((player, index) => {
const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
if (back > 0) {
player.stack = this.round(this.stackOf(state, player) + back);
returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
}
player.committed = 0;
player.amountOverride = null;
player.hasActed = false;
player.folded = false;
player.cards = ["", ""];
});
state.bettingOpen = false;
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
state.actingSeatIndex = null;
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
HoldemState.clearBoard(state);
// 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
@@ -325,6 +603,30 @@ const Betting = {
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
// A fresh deal starts on an empty felt. Dealing without moving the button —
// after a misdeal, or a table set up and dealt twice — would otherwise leave
// the last hand's board sitting there being ranked against the new cards.
HoldemState.clearBoard(state);
// 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.
//
// A busted player who hasn't rebought used to be dealt in anyway, and the
// result was a hand nobody could finish: the table promised a small blind from
// a seat with nothing to post it with, took it from nobody, put the action on
// the big blind with only their own money in front of them, and announced
// "3 players — all-in, running out the board" before a card was dealt.
//
// Sitting them out is what a dealer does. Seat, name and buy-in record are
// untouched, so dealing them back in is one tap when they buy chips.
this.brokeSeats(state).forEach(index => {
state.seats[index].inHand = false;
state.seats[index].folded = false;
});
// Position is read off the button all through the hand, so it has to be on
// somebody before the blinds are worked out — not on the empty seat 1 a
@@ -340,24 +642,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.
@@ -16,75 +16,147 @@ const CardPicker = {
state: null,
hooks: null,
// Which set of slots is being filled: a seat's two hole cards, or the five
// community cards. The grid, the duplicate check and the tap-to-fill behaviour are
// the same job either way — what changes is where the card is written and how many
// slots there are to walk along.
target: null,
seatIndex: null,
cardIndex: 0,
open(state, hooks, seatIndex, cardIndex) {
this.state = state;
this.hooks = hooks;
this.target = "seat";
this.seatIndex = seatIndex;
this.cardIndex = cardIndex;
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
close() {
document.getElementById("cardPickerOverlay").hidden = true;
// The board. `limit` is how many of the five are actually out — three on the flop,
// four on the turn — so the sheet never offers a slot for a card the dealer has not
// turned over yet.
openBoard(state, hooks, cardIndex, limit = 5) {
this.state = state;
this.hooks = hooks;
this.target = "board";
this.seatIndex = null;
this.boardLimit = Math.max(1, Math.min(5, limit));
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
// Every card sitting in front of anyone else. The slot being edited is excluded,
// so re-picking the card that's already there isn't treated as a duplicate.
onBoard() {
return this.target === "board";
},
// The slots the sheet is filling, whichever set that is.
slotValues() {
return this.onBoard()
? (this.state.board || []).slice(0, this.boardLimit)
: this.state.seats[this.seatIndex].cards;
},
write(index, card) {
if (this.onBoard()) {
if (!Array.isArray(this.state.board)) this.state.board = ["", "", "", "", ""];
this.state.board[index] = card;
return;
}
this.state.seats[this.seatIndex].cards[index] = card;
},
// Why the sheet is closing, because one caller needs to tell the difference.
//
// "filled" is the sheet finishing its own job — the last empty slot it was offering
// got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim.
// A single seat's cards don't care which. A showdown does: it walks the players one
// at a time, and the two answers are "next player" and "stop asking".
//
// The hooks are dropped before the callback runs, so a caller that opens the sheet
// again from inside `onDone` installs its own and this close can't fire twice.
close(reason = "dismissed") {
document.getElementById("cardPickerOverlay").hidden = true;
const done = this.hooks && this.hooks.onDone;
this.hooks = null;
if (done) done(reason);
},
// Every card already dealt somewhere — in front of a player or face up in the
// middle. The slot being edited is excluded, so re-picking the card that's already
// there isn't treated as a duplicate.
//
// The board belongs in here as much as the hands do, and in both directions: the
// seven of clubs cannot be on the flop and in somebody's hand, whichever of the two
// the dealer is filling in at the time.
cardsInUse() {
const used = new Map();
this.state.seats.forEach((player, seat) => {
(player.cards || []).forEach((card, slot) => {
if (!card) return;
if (seat === this.seatIndex && slot === this.cardIndex) return;
if (!this.onBoard() && seat === this.seatIndex && slot === this.cardIndex) return;
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
});
});
(this.state.board || []).forEach((card, slot) => {
if (!card) return;
if (this.onBoard() && slot === this.cardIndex) return;
used.set(card.toUpperCase(), "the board");
});
return used;
},
assign(card) {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = card;
this.write(this.cardIndex, card);
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
// Straight on to the second card — a dealer picking hole cards is always
// picking two, and making them re-open the picker doubles the taps.
if (this.cardIndex === 0 && !player.cards[1]) {
this.cardIndex = 1;
// Straight on to the next empty slot — a dealer picking hole cards is always
// picking two and a flop is always three, and making them re-open the picker
// between each one multiplies the taps by the only thing the sheet is for.
const values = this.slotValues();
const next = values.findIndex((value, i) => i > this.cardIndex && !value);
if (next !== -1) {
this.cardIndex = next;
this.render();
return;
}
this.close();
this.close("filled");
},
clearSlot() {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = "";
this.write(this.cardIndex, "");
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
const used = this.cardsInUse();
// What this slot is called out loud: the flop's three cards, then the turn, then
// the river — which is what a dealer is holding when they open this, rather than
// "card 4".
boardSlotLabel(slot) {
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
},
document.getElementById("cardPickerTitle").textContent =
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
render() {
const used = this.cardsInUse();
const values = this.slotValues();
document.getElementById("cardPickerTitle").textContent = this.onBoard()
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
// Which slot the next tap fills, switchable without closing the sheet.
const slots = document.getElementById("cardPickerSlots");
slots.innerHTML = "";
[0, 1].forEach(slot => {
values.forEach((_, slot) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerSlot";
btn.classList.toggle("active", slot === this.cardIndex);
const value = player.cards[slot] || "";
const value = values[slot] || "";
btn.textContent = value || "—";
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
btn.addEventListener("click", () => {
this.cardIndex = slot;
@@ -105,8 +177,12 @@ const CardPicker = {
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
if (takenBy) {
btn.title = takenBy === "the board"
? "Already face up on the board"
: `Already dealt to ${takenBy}`;
}
if ((values[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
@@ -46,7 +46,10 @@ const Toast = {
host.appendChild(toast);
// Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => toast.classList.add("toastVisible"));
requestAnimationFrame(() => {
toast.classList.add("toastVisible");
this.publishHeight();
});
this.timer = setTimeout(
() => this.dismiss(),
@@ -61,6 +64,24 @@ const Toast = {
}
const host = document.getElementById("toastHost");
if (host) host.innerHTML = "";
this.publishHeight();
},
// How much of the bottom of the window this is standing on.
//
// The toast is fixed, so it is not in the flow and nothing scrolling a control into
// view knows it is there — measured on the desktop sweep, tabbing to Common rulings
// while a toast was up left the focus ring entirely behind it. The stylesheet adds
// this to the bar's own height to work out where a focused control has to stop, so
// it is published rather than guessed: the toast is one line or two depending on the
// message, and on a narrow screen the same message is taller again.
publishHeight() {
if (typeof document === "undefined" || !document.documentElement) return;
const host = document.getElementById("toastHost");
const height = host && host.firstElementChild
? Math.round(host.getBoundingClientRect().height)
: 0;
document.documentElement.style.setProperty("--toastHeight", `${height}px`);
},
};
@@ -19,8 +19,12 @@ const HandHistoryExport = {
// text, so the one thing anybody opens this file to do — total a column and see
// 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 = []) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
toCsv(handHistory, standings = [], { notes = "" } = {}) {
// 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,
]);
});
@@ -41,18 +46,59 @@ const HandHistoryExport = {
rows.push(["Session Standings"]);
// Bought in and chips now are what a cash-out is settled from; paid in and
// won describe the hands. Both belong in the file the dealer hands over.
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
//
// Notes go last so the money columns keep the shape a spreadsheet expects.
// "IOU: $20 to Ann" is exactly the kind of thing that has to be in the file
// somebody settles up from, and it was only ever visible inside that seat's
// own editor.
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),
Money.forSheet(row.boughtIn || 0),
Money.forSheet(row.chipsNow || 0),
Money.forSheet(row.cashPosition || 0),
row.notes || "",
]);
});
// And the answer the standings are read to get to. Whoever opens this file
// wants the payment list, not to redo the arithmetic in a spreadsheet.
const plan = SettleUp.plan(standings);
if (plan.payments.length > 0) {
rows.push([]);
rows.push(["Settle Up"]);
rows.push(["Pays", "Receives", "Amount"]);
plan.payments.forEach(payment => {
rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
});
if (!plan.balanced) {
// The file is the paper trail, so the caveat travels with it rather
// than living only in a sidebar nobody exported.
rows.push([
plan.shortBy > 0
? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
: `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
]);
}
}
}
// The table's own notes — side bets, IOUs, the promo running until midnight.
// The dealer typed them because they matter to the night, and the export is the
// thing that outlives the browser tab; leaving them behind meant the one place
// the context was written down was the one place it couldn't be handed over.
// csvField quotes the line breaks, so a multi-line note survives as one cell.
if (notes && notes.trim()) {
rows.push([]);
rows.push(["Table Notes"]);
rows.push([notes.trim()]);
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
@@ -65,8 +111,8 @@ const HandHistoryExport = {
return `holdem-hand-history-${stamp}.csv`;
},
download(handHistory, standings = []) {
const csv = this.toCsv(handHistory, standings);
download(handHistory, standings = [], options = {}) {
const csv = this.toCsv(handHistory, standings, options);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -0,0 +1,285 @@
// What beats what.
//
// The dealer has always been the one who decides who won: the pot wizard asks "who won
// the hand?" and pays whoever gets ticked. That is the right default — most hands are
// won without a showdown, and at a home game the table reads the cards out loud between
// them. But when the board is on the felt and the hands are on their backs, the tool
// knows the answer, and reading it off a screen beats three people counting kickers.
//
// Cards are the same two-character strings the rest of the tool has always stored:
// a rank from `RANKS` and a suit from S, H, D, C. Case is not significant.
//
// Nothing in here touches the DOM, the table, or money. It compares cards, and the pot
// wizard decides what to do about it.
const HandRank = {
RANKS: "23456789TJQKA",
// High to low. The number is the hand's category and nothing else — kickers and
// the rest of the tie-breaking live in the `tiebreak` array beside it.
STRAIGHT_FLUSH: 8,
FOUR_OF_A_KIND: 7,
FULL_HOUSE: 6,
FLUSH: 5,
STRAIGHT: 4,
THREE_OF_A_KIND: 3,
TWO_PAIR: 2,
ONE_PAIR: 1,
HIGH_CARD: 0,
// 2 through 14. Null for anything that isn't a card, which is how a half-filled
// slot gets told apart from a bad one.
rankValue(card) {
if (typeof card !== "string" || card.length < 2) return null;
const index = this.RANKS.indexOf(card[0].toUpperCase());
return index < 0 ? null : index + 2;
},
suitOf(card) {
if (typeof card !== "string" || card.length < 2) return null;
const suit = card[card.length - 1].toUpperCase();
return "SHDC".includes(suit) ? suit : null;
},
isCard(card) {
return this.rankValue(card) !== null && this.suitOf(card) !== null;
},
// Singular and plural, for reading a hand out loud.
NAMES: {
14: ["ace", "aces"], 13: ["king", "kings"], 12: ["queen", "queens"],
11: ["jack", "jacks"], 10: ["ten", "tens"], 9: ["nine", "nines"],
8: ["eight", "eights"], 7: ["seven", "sevens"], 6: ["six", "sixes"],
5: ["five", "fives"], 4: ["four", "fours"], 3: ["three", "threes"],
2: ["two", "twos"],
},
name(rank, plural = false) {
const pair = this.NAMES[rank];
return pair ? pair[plural ? 1 : 0] : "?";
},
// ---- The five-card hand -------------------------------------------------------
// Exactly five cards, ranked. Everything else is built on this.
//
// Five rather than seven because five is the hand a player actually holds, and it
// is small enough to check by hand — which is the whole argument for doing it this
// way. The seven-card version below tries all twenty-one five-card hands and keeps
// the best, which is a hand of poker's own definition of "your best five" written
// out literally. A dealer resolves one pot a hand with at most a dozen players in
// it; two hundred and fifty comparisons of five cards is not a cost worth trading
// a subtler algorithm's correctness for.
evaluate5(cards) {
if (!Array.isArray(cards) || cards.length !== 5) return null;
if (!cards.every(card => this.isCard(card))) return null;
const ranks = cards.map(card => this.rankValue(card)).sort((a, b) => b - a);
const suits = cards.map(card => this.suitOf(card));
const flush = suits.every(suit => suit === suits[0]);
// How many of each rank, ordered by how many first and by rank second — so
// the front of this list is always the rank that decides the hand.
const counts = new Map();
ranks.forEach(rank => counts.set(rank, (counts.get(rank) || 0) + 1));
const groups = [...counts.entries()]
.sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]));
const shape = groups.map(([, count]) => count);
const byGroup = groups.map(([rank]) => rank);
const straightHigh = this.straightHigh(ranks);
const made = (category, tiebreak) => ({ category, tiebreak, cards: [...cards] });
if (flush && straightHigh) return made(this.STRAIGHT_FLUSH, [straightHigh]);
if (shape[0] === 4) return made(this.FOUR_OF_A_KIND, byGroup);
if (shape[0] === 3 && shape[1] === 2) return made(this.FULL_HOUSE, byGroup);
if (flush) return made(this.FLUSH, ranks);
if (straightHigh) return made(this.STRAIGHT, [straightHigh]);
if (shape[0] === 3) return made(this.THREE_OF_A_KIND, byGroup);
if (shape[0] === 2 && shape[1] === 2) return made(this.TWO_PAIR, byGroup);
if (shape[0] === 2) return made(this.ONE_PAIR, byGroup);
return made(this.HIGH_CARD, ranks);
},
// The top card of the straight these five ranks make, or null.
//
// The wheel is the exception the rest of the ordering has to be told about: A-2-3-4-5
// is a straight, and it is the *lowest* one, so its ace counts as five rather than
// as fourteen. Getting that wrong is how A-2-3-4-5 comes to beat 10-J-Q-K-A.
straightHigh(sortedDesc) {
const distinct = [...new Set(sortedDesc)];
if (distinct.length !== 5) return null;
if (distinct[0] - distinct[4] === 4) return distinct[0];
// Ace, five, four, three, two.
if (distinct[0] === 14 && distinct[1] === 5 && distinct[4] === 2) return 5;
return null;
},
// ---- The best five out of however many ----------------------------------------
// The best five-card hand available from five, six or seven cards — hole cards and
// board together, which is what Hold'em asks for. Returns null if there aren't five
// real cards, which is the ordinary state of a hand still being dealt.
best(cards) {
const real = (cards || []).filter(card => this.isCard(card));
if (real.length < 5) return null;
// A card in two places at once is a mis-entry, not a hand — say so rather than
// ranking a board that has the same king on it twice.
if (new Set(real.map(card => card.toUpperCase())).size !== real.length) return null;
let best = null;
this.eachFive(real, five => {
const hand = this.evaluate5(five);
if (!best || this.compare(hand, best) > 0) best = hand;
});
return best;
},
// Every five-card subset, without building an array of them.
eachFive(cards, visit) {
const n = cards.length;
for (let a = 0; a < n - 4; a++) {
for (let b = a + 1; b < n - 3; b++) {
for (let c = b + 1; c < n - 2; c++) {
for (let d = c + 1; d < n - 1; d++) {
for (let e = d + 1; e < n; e++) {
visit([cards[a], cards[b], cards[c], cards[d], cards[e]]);
}
}
}
}
}
},
// Positive if `a` wins, negative if `b` does, zero for a chop. A chop is a real
// answer here rather than a rounding of one: two players playing the same board
// split the pot, and the fifth card decides it far more often than a table expects.
compare(a, b) {
if (!a && !b) return 0;
if (!a) return -1;
if (!b) return 1;
if (a.category !== b.category) return a.category - b.category;
const length = Math.max(a.tiebreak.length, b.tiebreak.length);
for (let i = 0; i < length; i++) {
const diff = (a.tiebreak[i] || 0) - (b.tiebreak[i] || 0);
if (diff !== 0) return diff;
}
return 0;
},
// ---- Saying it out loud -------------------------------------------------------
// What a dealer says when they turn the hand over. Named to the level that settles
// it and no further: "two pair, aces and eights" is the call, and the kicker only
// gets a mention where the tool has had to use one to break a tie.
describe(hand) {
if (!hand) return "";
const [first, second] = hand.tiebreak;
switch (hand.category) {
case this.STRAIGHT_FLUSH:
return first === 14 ? "Royal flush" : `Straight flush, ${this.name(first)} high`;
case this.FOUR_OF_A_KIND:
return `Four of a kind, ${this.name(first, true)}`;
case this.FULL_HOUSE:
return `Full house, ${this.name(first, true)} full of ${this.name(second, true)}`;
case this.FLUSH:
return `Flush, ${this.name(first)} high`;
case this.STRAIGHT:
return `Straight, ${this.name(first)} high`;
case this.THREE_OF_A_KIND:
return `Three of a kind, ${this.name(first, true)}`;
case this.TWO_PAIR:
return `Two pair, ${this.name(first, true)} and ${this.name(second, true)}`;
case this.ONE_PAIR:
return `Pair of ${this.name(first, true)}`;
default:
return `${this.name(first).charAt(0).toUpperCase()}${this.name(first).slice(1)} high`;
}
},
// The same call with the article a table would put in front of it. Not derivable
// from the first letter: it is "a full house" and "a flush", but "four of a kind"
// and "two pair" take none at all, and "an ace high" is not something anybody says.
// So it belongs to the category, like the name does.
ARTICLED: {
8: hand => (hand.tiebreak[0] === 14 ? "a royal flush" : "a"),
7: () => null,
6: () => "a",
5: () => "a",
4: () => "a",
3: () => null,
2: () => null,
1: () => "a",
0: () => null,
},
// "wins with X" — the phrase, ready to drop into a sentence.
phrase(hand) {
if (!hand) return "the best hand";
const described = this.describe(hand);
const article = (this.ARTICLED[hand.category] || (() => null))(hand);
if (article === null) return described.toLowerCase();
if (article !== "a") return article;
return `a ${described.toLowerCase()}`;
},
// ---- The showdown -------------------------------------------------------------
// Rank a set of players against a board.
//
// `players` is [{ id, cards }, ...] — the two in front of each of them. Every player
// is returned whether or not they could be ranked, in the order they were given, so
// the caller can show a row for each and say which ones it could not read. `place`
// is 1 for the winner and shared by a chop; a player whose cards aren't known has a
// null hand and no place.
//
// `complete` is the question the pot wizard actually asks: can this be trusted to
// pay the table. It is only true when the board is all five cards and every single
// player handed in has two of their own — one unknown hand and the tool cannot say
// who won, only who is winning so far, which is not the same thing and must not be
// dressed up as it.
showdown(board, players) {
const boardCards = (board || []).filter(card => this.isCard(card));
const boardComplete = boardCards.length === 5;
const ranked = (players || []).map(player => {
const hole = (player.cards || []).filter(card => this.isCard(card));
const hand = hole.length === 2 && boardComplete
? this.best([...hole, ...boardCards])
: null;
return { id: player.id, cards: hole, hand, place: null };
});
const readable = ranked.filter(row => row.hand);
// A duplicated card anywhere means somebody has been mis-entered, and the
// ranking would be of a deck that doesn't exist.
const all = [...boardCards, ...ranked.flatMap(row => row.cards)].map(c => c.toUpperCase());
const duplicated = new Set(all).size !== all.length;
const sorted = [...readable].sort((a, b) => this.compare(b.hand, a.hand));
let place = 0;
sorted.forEach((row, i) => {
if (i === 0 || this.compare(row.hand, sorted[i - 1].hand) !== 0) place = i + 1;
row.place = place;
});
const winners = sorted.filter(row => row.place === 1);
const complete = boardComplete && !duplicated &&
ranked.length > 0 && readable.length === ranked.length;
return {
players: ranked,
winners: complete ? winners.map(row => row.id) : [],
boardComplete,
duplicated,
complete,
// Who the tool could not read, so the dialog can name them rather than
// just declining to help.
unknown: ranked.filter(row => !row.hand).map(row => row.id),
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HandRank;
}
+794 -28
View File
@@ -5,6 +5,32 @@ document.addEventListener("DOMContentLoaded", () => {
// `hidden` attribute they all already toggle.
Modal.mount();
// ---- A scroll is not an edit ----
//
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
// wheel drive it. Every other way of driving one is deliberate: the arrow keys, the
// little stepper arrows, typing. Scrolling is the one actuation nobody means — the
// dealer is reaching for another panel and the cursor happens to be over a field
// they touched a moment ago.
//
// Measured on the running page, at 1280x900 with the sidebar open. Focus the small
// blind, scroll: $5 becomes $5.50. Focus a seat's stack, scroll: $500 becomes $501.
// Both are written straight through to the saved table, because these fields save on
// `input` like everything else here. Nothing is announced, nothing looks wrong, and
// the tool has invented a chip.
//
// Blur rather than preventDefault. Cancelling the event would stop the value moving
// and stop the page moving with it, which turns a silent bug into a sidebar that
// mysteriously will not scroll. Dropping focus lets the scroll do what the dealer
// asked for and leaves the number alone — and losing focus on a field you have just
// scrolled away from is honest rather than surprising.
//
// Captured at the document, so it covers the fields the seat editor and the chip
// legend build on the fly as well as the ones in the markup.
document.addEventListener("wheel", event => {
if (Money.wheelWouldEdit(event.target, document.activeElement)) event.target.blur();
}, { passive: true });
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
// by reference to every module at mount time, so it has to be refilled in place —
// reassigning the variable would leave the renderer and the betting bar pointing at
@@ -56,7 +82,84 @@ document.addEventListener("DOMContentLoaded", () => {
},
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
// The waiting list, from the Session panel and from an empty chair's menu.
// The state change goes inside runUndoable, not before it: the snapshot is
// taken as the first thing that happens, so anything done ahead of it is
// already baked into what Undo would restore.
seatWaiting: (id, index) => {
const entry = (state.waitingList || []).find(row => row.id === id);
const seat = state.seats[index];
if (!entry || !seat) {
Toast.show("They aren't on the list any more.", { duration: 3000 });
hooks.rerender();
return;
}
const occupied = HoldemState.seatInPlay(seat);
if (occupied) {
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
hooks.rerender();
return;
}
runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
() => HoldemState.seatFromWaitingList(state, id, index));
},
dropWaiting: id => {
const entry = (state.waitingList || []).find(row => row.id === id);
if (!entry) return;
runUndoable(`${entry.name} taken off the waiting list.`,
() => HoldemState.removeFromWaitingList(state, id));
},
onGiveButton: index => seatMenuHooks.makeDealer(index),
// Removing a chip colour, which is only sometimes a small thing.
//
// A colour nobody has counted is a row in a legend, and taking it out should
// cost a click and nothing else. A colour seats are holding is money: the
// legend is what prices their chips, so the row leaving takes their chips off
// the table with it — and it cannot be put back, because a colour added again
// is a new one and the counts belong to the old.
//
// So the friction is on the second case only. A confirmation that fires every
// time is one nobody reads by the third night, and the one that matters here
// would go the same way as the four before it that didn't.
removeChipColor: async id => {
const usage = HoldemState.chipColorUsage(state, id);
if (!usage.chip) return;
const name = Number(usage.chip.value) > 0
? `${Money.format(usage.chip.value)} chip`
: "chip colour";
if (usage.total !== 0) {
const seatCount = `${usage.seats.length} ${usage.seats.length === 1 ? "seat" : "seats"}`;
const held = usage.seats.map(seat => `${seat.name} ${seat.count}`).join(", ");
const ok = await Dialog.confirm({
// Both directions are named rather than only the loss. Going up is
// the odd one — seats falling back to the table's standing ante once
// their chips are gone — and a dealer who is told the total will move
// and then finds it moved the other way has been told something false.
title: usage.total > 0
? `Remove the ${name} and ${Money.format(usage.total)} with it?`
: `Remove the ${name} and put the table up ${Money.format(-usage.total)}?`,
message:
`${usage.chips} of them are counted onto ${seatCount} — ${held} — and the table ` +
`${usage.total > 0 ? "loses" : "gains"} ${Money.format(Math.abs(usage.total))} ` +
`when the colour goes. Undo will put it back; adding the colour again will not.`,
confirmLabel: "Remove it",
danger: usage.total > 0,
});
if (!ok) return;
}
runUndoable(
usage.total > 0
? `${name} removed — ${Money.format(usage.total)} off the table.`
: usage.total < 0
? `${name} removed — table up ${Money.format(-usage.total)}.`
: `${name} removed.`,
() => HoldemState.removeChipColor(state, id));
},
};
const seatMenuHooks = {
@@ -96,18 +199,73 @@ 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) => {
const movedName = nameOf(from);
const intoName = nameOf(to);
const wasEmpty = Render.isEmptySeat(state.seats[to]);
runUndoable(
wasEmpty
? `${movedName} moved to seat ${to + 1}.`
: `${movedName} and ${intoName} changed seats.`,
() => HoldemState.swapSeats(state, from, to));
},
makeDealer: index => {
// Moving the button to where it already is is not a move. Saying so costs a
// toast nobody asked for, and mid-hand it would take the blinds back off the
// felt and re-post the same chips to say nothing happened.
if (index === state.dealerSeatIndex) return;
// Before the deal there's nothing posted, so this is just moving a marker.
// After it, the blinds have to follow the button or the badges and the
// money disagree — but that's a real thing happening to real chips, and
// the dealer gets told rather than finding out later.
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
const willRepost = Betting.blindsArePosted(state);
seatAction(index, willRepost
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
: `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);
});
},
@@ -124,33 +282,76 @@ document.addEventListener("DOMContentLoaded", () => {
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
const label = which === "sb" ? "small blind" : "big blind";
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
// Only a hand that is already open has blinds to move. Before the deal this
// is aiming the next hand, and re-posting would have dealt it — the dealer
// says who the blind is well before they say "cards".
const posted = Betting.blindsArePosted(state);
seatAction(index, posted
? `${nameOf(index)} posts the ${label}.`
: `${nameOf(index)} has the ${label} next hand.`, () => {
state.blindOverride = which === "sb"
? { sbIndex: index, bbIndex: partner }
: { sbIndex: partner, bbIndex: index };
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
if (posted) Betting.repostBlinds(state);
});
},
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
state.blindOverride = null;
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
}),
clearBlindOverride: () => {
const posted = Betting.blindsArePosted(state);
seatAction(state.dealerSeatIndex, posted
? "Blinds follow the button again — taken back and re-posted."
: "Blinds follow the button again.", () => {
state.blindOverride = null;
if (posted) Betting.repostBlinds(state);
});
},
editSeat: index => Render.toggleSeatEditor(index),
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
// Buying chips puts you back in the game — see Betting.buyInDealsThemIn.
const dealBackIn = Betting.buyInDealsThemIn(state.seats[seatIndex]);
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
const message = `${nameOf(seatIndex)} added ${Money.format(amount)}` +
(dealBackIn
? handRunning ? " — in from the next hand." : " — dealt in."
: ".");
seatAction(seatIndex, message, p => {
// Chips in front and cash in are different questions — the stack moves
// every hand, what they've handed over only moves when they buy.
player.stack = Money.round(Betting.stackOf(state, player) + amount);
player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
p.stack = Money.round(Betting.stackOf(state, p) + amount);
p.buyIns = Money.round((Number(p.buyIns) || 0) + amount);
if (dealBackIn) {
p.inHand = true;
// Same rule "Deal in" follows: you can't buy into a pot you haven't
// been paying for, and `folded` is exactly that state for one hand.
p.folded = handRunning;
}
});
}),
};
Render.mount(state, hooks);
// Table Setup used to ship open, so the tool's first screen was its most crowded
// one: seat count, blind direction, dedicated dealer, both blinds, starting stack
// and two sub-panels, all under a felt the dealer had come here to look at. It is
// the right panel to have open on a table nobody has touched, and the wrong one to
// have open on the fourth hand of a night.
//
// Decided once, here, rather than on every render — a panel that reopened itself
// whenever the table emptied would fight the dealer who had just shut it.
(() => {
const panel = document.getElementById("tableSetupPanel");
if (!panel) return;
const fresh = (Number(state.handNumber) || 1) === 1 &&
!state.seats.some(seat => HoldemState.seatInPlay(seat));
panel.open = fresh;
})();
const openPotWizard = () => {
// Chips still in front of players belong in the pot being priced, so bank them
// before the wizard reads anyone's contribution.
@@ -158,11 +359,20 @@ 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);
}
// Anything the pot was priced with but nobody actually paid — the
// base pot and counted chips of the no-live-betting flow — comes off
// the stacks that owe it before the winner is paid. Otherwise the
// table gains the whole pot out of nowhere.
Betting.collectAssumedContributions(state);
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts);
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);
@@ -185,6 +395,17 @@ document.addEventListener("DOMContentLoaded", () => {
save: hooks.save,
rerender: hooks.rerender,
resolvePot: openPotWizard,
keepStorage: () => askToKeepStorage(),
// The first thing a dealer does on a table nobody is sitting at. Opens the
// seat editor on the first free chair — the same panel tapping the seat gives
// them, so the button teaches the gesture rather than replacing it.
seatFirstPlayer: () => {
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
Render.toggleSeatEditor(index < 0 ? 0 : index);
const panel = document.getElementById("seatEditorPanel");
Render.bringIntoView(panel, { block: "nearest" });
},
beginAction: label => {
actionHistory.push({ label, snapshot: snapshotState() });
@@ -266,12 +487,11 @@ document.addEventListener("DOMContentLoaded", () => {
// in progress. Shrinking the table throws these away permanently, and it also
// orphans them in the session standings, where they keep their winnings but lose
// the stack and buy-in the settle-up is worked out from.
const seatHasSomeoneIn = player =>
!!(player.name && player.name.trim()) ||
player.inHand ||
(Number(player.stack) || 0) > 0 ||
(Number(player.buyIns) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0;
// One question, asked in one place. This predicate existed here, in the seat menu,
// in the action bar and twice in the state module, in four slightly different
// spellings — and a chair that counts as occupied for the waiting list but empty
// for the seat count is how somebody's chips get thrown away.
const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
document.getElementById("seatCountInput").addEventListener("change", async e => {
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
@@ -315,9 +535,24 @@ document.addEventListener("DOMContentLoaded", () => {
Render.renderTableHint();
});
document.getElementById("trackBoardToggle").addEventListener("change", e => {
state.trackBoard = e.target.checked;
// Turning it off leaves whatever was entered in place rather than wiping it —
// a dealer who taps this by mistake mid-hand can tap it back and the flop is
// still there. The next hand clears it the way it clears everything else.
hooks.save(state);
Render.renderAll();
});
document.getElementById("tableNotes").addEventListener("input", e => {
state.notes = e.target.value;
hooks.save(state);
// The one field on the page that must not go through renderAll: it would write
// the textarea's own value back into it on every keystroke and put the caret at
// the end. The panel's shut-state summary still has to keep up, though, and it
// is the only thing on the page that reads the notes — so it gets called on its
// own. It touches the six <span>s in the sidebar headers and nothing else.
Render.renderPanelGlances();
});
document.getElementById("addChipColorBtn").addEventListener("click", () => {
@@ -330,6 +565,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
@@ -347,17 +635,30 @@ 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.`
: "";
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
// Written down as well as announced. The toast is a moment; the bank check
// is asked at cash-out, and until now it saw only that the chips were gone
// and reported them as missing.
if (unresolved > Betting.EPSILON_CHIPS) {
state.discarded = Money.round((Number(state.discarded) || 0) + unresolved);
}
HoldemState.startNextHand(state);
});
});
@@ -378,6 +679,93 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show("Table reset.", { duration: 2500 });
});
// A pre-filled amount is a suggestion, so reaching for the box replaces it.
//
// Delegated from the document rather than wired per field: the raise pad, the
// add-chips sheet and the seat editor all build their inputs fresh every render,
// and a box added later should not have to remember to ask for this.
document.addEventListener("focusin", event => {
const field = event.target;
if (!Money.selectsOnFocus(field)) return;
if (!field.value) return;
// After the browser has finished placing its own caret, or it wins the race
// and the selection collapses again the moment the tap completes.
setTimeout(() => {
if (document.activeElement !== field) return;
try {
field.select();
} catch {
// A number input refuses setSelectionRange in some browsers; select()
// is the one that works on both, and if neither does the field is
// simply left as it was.
}
}, 0);
});
// Focus that lands underneath something fixed is moved out from under it.
//
// scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It
// works when the browser has to scroll to reach the control at all — then the
// padding tells it where to stop. A control the browser already considers visible is
// one it will not scroll to at all, and a fixed overlay sitting on top of it does
// not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a
// toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved
// nothing, because "nearest" was already satisfied.
//
// So the overlap is worked out here and the page is scrolled by exactly it. Named
// overlays rather than every fixed element on the page, because this runs on every
// focus change and walking the whole DOM to find three known things would be work
// done ten times a second to reach the same answer. Anything inside one of them is
// left alone — the bar's own buttons are meant to be on top of the page.
const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel";
document.addEventListener("focusin", event => {
const el = event.target;
if (!el || typeof el.getBoundingClientRect !== "function") return;
if (el.closest && el.closest(FIXED_OVERLAYS)) return;
const box = el.getBoundingClientRect();
if (box.width === 0 || box.height === 0) return;
let lift = 0;
document.querySelectorAll(FIXED_OVERLAYS).forEach(node => {
const style = getComputedStyle(node);
if (node.hidden || style.visibility === "hidden" || style.display === "none") return;
if (style.position !== "fixed") return;
const over = node.getBoundingClientRect();
if (over.width === 0 || over.height === 0) return;
// Only what is actually in the way: an overlay off to one side, like the
// rail on a phone held sideways, is not covering a control in the middle.
if (box.left >= over.right || box.right <= over.left) return;
if (box.bottom <= over.top || box.top >= over.bottom) return;
lift = Math.max(lift, box.bottom - over.top + 8);
});
// Instant rather than smooth. This is the page getting out of the way of
// something the dealer has already arrived at, and animating it would put the
// control they are typing into in motion under them.
if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" });
// Scrolling is not always enough. The page only has as much room below its
// content as the padding under the app gives it, and that is sized for the bar
// — the toast stands on top of the bar, so a control at the very end of the page
// can be lifted clear of one and not the other. Measured at 1280x900: 94px of
// lift wanted, 26px of page to do it in.
//
// Sizing the padding for the toast as well would fix it by making the page grow
// and shrink by 64px every time anything is announced, which is a worse thing to
// do to a dealer than the fault. So the toast gives way instead: it is an
// eight-second message about something that has already happened, and it is
// standing on a control somebody has just navigated to. Only after the scroll
// has been tried, so a toast that is not in the way is left alone.
const toast = document.querySelector("#toastHost .toast");
if (!toast) return;
const after = el.getBoundingClientRect();
const over = toast.getBoundingClientRect();
const stillCovered = after.left < over.right && after.right > over.left &&
after.bottom > over.top && after.top < over.bottom;
if (stillCovered) Toast.dismiss();
});
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
[
@@ -392,8 +780,48 @@ 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());
// The two ways out of the sheet that every other overlay in the tool already had.
//
// Modal.mount() covers the six whose ids end in "Overlay"; this one cannot join
// them, because above 700px it is not a sheet at all — it is the sidebar panel the
// seat's fields live in, and a dialog is the wrong thing for Modal to make of it.
// So the two behaviours it was missing are wired here against the same state the
// scrim is drawn from.
document.getElementById("seatSheetScrim")
.addEventListener("click", () => Render.closeSeatEditor());
// Not in capture, and behind Modal's own check: a card picker opened *from* the
// sheet is on top of it, and Escape there means "stop picking a card", not "throw
// the seat away". Modal stops that event before this listener ever sees it; the
// guard is for the case where it hasn't, and costs nothing.
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
if (Modal.open.length > 0) return;
if (!document.body.classList.contains("seatSheetOpen")) return;
Render.closeSeatEditor();
});
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
document.getElementById("addChipsOverlay").addEventListener("click", e => {
@@ -406,7 +834,90 @@ document.addEventListener("DOMContentLoaded", () => {
// Escape is Modal's job now, for this sheet and the five others that never had it.
const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; };
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
// The one time the browser refuses to save, say so out loud as well as leaving the
// standing warning in the Session panel. Set before the first render so a failure
// during start-up is caught too.
// Another tab writing to the same table. Said once, with the way out attached —
// reloading here adopts whatever that tab has done, which is the only resolution
// that doesn't throw somebody's work away.
HoldemState.onOtherTab = () => {
Toast.show(
"This table is open in another tab. Whichever acts last wins.",
{ actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 });
Render.renderStorageWarning();
};
HoldemState.watchOtherTabs();
HoldemState.onSaveFailed = () => {
Toast.show(
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
{ duration: 12000 });
Render.renderStorageWarning();
};
// The shortcut legend, built from the same list the handlers are keyed off, so a
// shortcut can't exist without a line here saying what it does.
const legend = document.getElementById("shortcutLegend");
if (legend) {
Shortcuts.LEGEND.forEach(entry => {
const term = document.createElement("dt");
entry.keys.forEach(key => {
const kbd = document.createElement("kbd");
kbd.className = "shortcutKey";
kbd.textContent = key;
term.appendChild(kbd);
});
const detail = document.createElement("dd");
detail.textContent = entry.what;
legend.appendChild(term);
legend.appendChild(detail);
});
}
// An index across the top of the rulings sheet, one chip per ruling.
//
// This is opened mid-argument with players waiting, and it was nine rulings of
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
// the odd-chip rule had to read past seven others to find it.
//
// Built from the sections rather than written out beside them, so a ruling added
// later turns up in the index without anyone having to remember to add it twice.
const buildRulingsIndex = () => {
const index = document.getElementById("rulingsIndex");
if (!index || index.childElementCount > 0) return;
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
const heading = section.querySelector("h3");
if (!heading) return;
if (!section.id) section.id = `ruling${i + 1}`;
const jump = document.createElement("button");
jump.type = "button";
jump.className = "rulingsIndexBtn";
// A short label where the ruling gives one. The headings are sentences —
// "A short all-in doesn't reopen the betting" — and nine of those stacked
// one per row made the index 342px tall on a phone, which is most of the
// sheet before the first ruling. "Short all-in" says the same thing to
// somebody who is looking for it, and three fit on a line.
jump.textContent = section.dataset.short || heading.textContent;
// The full heading is still what it announces, so the short label never
// costs a screen reader the sentence.
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
jump.addEventListener("click", () => {
Render.bringIntoView(section, { block: "start" });
// The heading is what the dealer is now reading, so that is where the
// keyboard and a screen reader should be too.
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true });
});
index.appendChild(jump);
});
};
document.getElementById("openRulingsBtn").addEventListener("click", () => {
buildRulingsIndex();
rulingsOverlay.hidden = false;
});
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
@@ -438,13 +949,268 @@ 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;
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
// 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
// show it in, so this is checked here rather than wired as a callback. The standing
// warning in the Session panel doesn't need it — that reads the flag every render —
// but an empty table is a thing a dealer needs told, not left to notice.
if (HoldemState.loadFailed) {
Toast.show(
"The saved table couldn't be read, so this one started empty.",
{ duration: 12000 });
}
// ---- Keeping the screen on ----
//
// The lock itself is taken and dropped in renderAll, because that is the one place
// every start and end of a hand already passes through. All that lives here is the
// dealer's say in it: the toggle, and the way out offered by the toast the first
// time it takes hold.
WakeLock.hooks = {
turnOff: () => {
state.keepScreenAwake = false;
hooks.save(state);
Render.renderAll();
Toast.show("The screen will sleep as usual. Table Setup has it if you want it back.");
},
};
WakeLock.start(() => state);
document.getElementById("keepScreenAwakeToggle").addEventListener("change", e => {
state.keepScreenAwake = e.target.checked;
hooks.save(state);
// renderAll does the acquiring and releasing, so turning this off mid-hand
// drops the lock in the same tick rather than at the end of the hand.
Render.renderAll();
});
// ---- Asking the browser to keep the night ----
//
// Reported rather than announced. A dealer opening Save & reset is already thinking
// about losing the table, and that is the only moment this is worth a sentence.
const showStorageDurability = async () => {
const hint = document.getElementById("storageDurabilityHint");
const button = document.getElementById("keepStorageBtn");
if (!hint || !button) return;
if (!HoldemState.supportsDurableStorage()) {
// Nothing to say and nothing to offer. A browser this old still keeps the
// table exactly as well as it did yesterday.
hint.hidden = true;
button.hidden = true;
return;
}
const durable = await HoldemState.isDurable();
hint.hidden = false;
hint.textContent = durable
? "This browser has promised to keep it: the table survives the phone running short on space."
: "This browser may clear the table on its own if the phone runs short on space.";
button.hidden = durable;
};
// Fires on the first deal of every session, which costs nothing after the first
// time it is granted — persisted() is checked before persist() is asked for.
const askToKeepStorage = async () => {
await HoldemState.requestDurable();
showStorageDurability();
};
document.getElementById("keepStorageBtn").addEventListener("click", async () => {
const granted = await HoldemState.requestDurable();
showStorageDurability();
// Said either way, because a button that sometimes does nothing visible is a
// button a dealer presses four more times.
Toast.show(granted
? "This browser will keep the table now."
: "This browser wouldn't promise. Save the table to a file if tonight matters.");
});
showStorageDurability();
// ---- Working with no signal ----
//
// The night already survives anything — it lives in localStorage — but the page
// did not. Reload in a back room with one bar of signal and a dealer with nine
// players waiting got a browser error page. The worker below keeps the tool itself
// on the phone; see wwwroot/casino/sw.js for what it does and doesn't cache.
//
// The list of files to keep is read off this document rather than written down in
// the worker. Every stylesheet and script the page actually loaded is right here in
// the DOM, already carrying the ?v= release string from _Layout — so the list
// cannot drift from the markup, which is the way a hand-kept one always fails: it
// stays right until somebody adds a script tag, and then the tool is quietly
// offline-broken until a dealer finds out the hard way.
const MANIFEST_URL = "/casino/holdem.webmanifest";
// The icons are the one part of the shell the document doesn't mention: the browser
// reads them out of the manifest itself, so no <link> or <script> in the DOM points
// at them and the sweep above misses all four. An installed app that was launched
// with no signal then had no icon to show for itself.
//
// Read out of the manifest rather than typed out here, for the same reason the rest
// of the list is read out of the document — a copy of a list is a list that is one
// edit away from being wrong, and this particular wrongness is invisible until
// somebody is standing in a back room with no signal.
const manifestIcons = async () => {
try {
// The cache before the network, which is backwards from how the rest of this
// worker thinks, and right here. Offline, a plain fetch for the manifest goes
// to the network, fails, and logs ERR_INTERNET_DISCONNECTED to the console on
// every single launch — noise on the one screen where a real error needs to
// be visible, in service of re-reading a list of four icon paths that has not
// changed since the manifest was written and is already cached anyway.
const res = (typeof caches !== "undefined" && await caches.match(MANIFEST_URL))
|| await fetch(MANIFEST_URL);
if (!res || !res.ok) return [];
const manifest = await res.json();
return (manifest.icons || [])
.map(icon => icon && icon.src)
.filter(Boolean)
.map(src => new URL(src, location.origin).href);
} catch {
return [];
}
};
const registerWorker = async () => {
if (!("serviceWorker" in navigator)) return;
const shell = [
location.pathname,
MANIFEST_URL,
...await manifestIcons(),
// Stylesheets, plus the icons the document points at itself — iOS reads
// none of the manifest's icons and only <link rel="apple-touch-icon">, so
// an installed iPhone launched with no signal needs this one specifically.
...[...document.querySelectorAll(
'link[rel="stylesheet"][href], link[rel="apple-touch-icon"][href], link[rel="icon"][href]'
)].map(el => el.href),
...[...document.querySelectorAll("script[src]")].map(el => el.src),
].filter(url => new URL(url, location.origin).origin === location.origin);
try {
const reg = await navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" });
// Whichever worker this registration has, at whatever stage it has reached.
//
// This used to fall back on navigator.serviceWorker.ready, which resolves
// with the registration *controlling this page* — and so never resolves at
// all on a page outside the worker's scope. That is not hypothetical: it is
// exactly what /Casino/TexasHoldem did before the redirect in Program.cs,
// and the symptom was the worst kind. The registration succeeded, the worker
// activated, nothing was logged, and the cache stayed completely empty — a
// dealer who had "loaded the tool for tonight" had loaded nothing.
const worker = reg.installing || reg.waiting || reg.active;
if (!worker) return;
const send = () => worker.postMessage({ type: "precache", urls: shell });
if (worker.state === "activated") send();
else worker.addEventListener("statechange", () => {
if (worker.state === "activated") send();
});
} catch {
// No worker means no offline, and nothing else. Every other part of the
// tool works exactly as it did, so this is not worth a toast at a table.
}
};
// After load, not during it: registering competes with the page's own scripts for
// the network, and the first paint matters more than the second visit does.
if (document.readyState === "complete") registerWorker();
else window.addEventListener("load", registerWorker, { once: true });
});
@@ -48,6 +48,137 @@ const Money = {
return (this.toCents(amount) / 100).toFixed(2);
},
// The chips to actually push, for an amount.
//
// A payout figure is the answer to the arithmetic, not to the dealer's job: they
// still have to work out that $132 is two blacks, a green, a red and two whites
// while six people watch. The chip legend already knows the denominations.
//
// Largest first, which is greedy and therefore not optimal for an arbitrary set of
// values — but poker chips are not arbitrary. Every real set is canonical (each
// denomination divides the next: 1, 5, 25, 100, 500), and greedy is exactly optimal
// on those. A dealer who invents a 3-and-7 chip set gets a valid answer that might
// use one chip more than it had to.
//
// 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 };
const values = (legend || [])
.map(chip => ({ chip, cents: this.toCents(chip.value) }))
.filter(entry => entry.cents > 0)
.sort((a, b) => b.cents - a.cents);
const chips = [];
values.forEach(entry => {
const count = Math.floor(cents / entry.cents);
if (count <= 0) return;
cents -= count * entry.cents;
chips.push({ value: this.fromCents(entry.cents), count, color: entry.chip.color });
});
return { chips, remainder: this.fromCents(cents) };
},
// "2×$50, 1×$25, 2×$1" — the breakdown as a dealer would say it out loud.
//
// A table with no chip legend gets nothing rather than "$132 no chip covers": it
// hasn't told us its denominations, so there is no advice to give and the amount
// stands on its own. A legend that exists but can't reach the amount is different,
// and does say so.
formatChipBreakdown(amount, legend) {
const hasDenominations = (legend || []).some(chip => this.toCents(chip.value) > 0);
if (!hasDenominations) return "";
const { chips, remainder } = this.chipBreakdown(amount, legend);
if (chips.length === 0 && remainder === 0) return "";
const parts = chips.map(chip => `${chip.count}×${this.format(chip.value)}`);
if (remainder > 0) parts.push(`${this.format(remainder)} no chip covers`);
return parts.join(", ");
},
// Should focusing this field select what's already in it?
//
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
// offers the minimum, Add chips offers a buy-in, the seat editor offers the stack
// it already has. A dealer taps the box because they want a *different* number,
// and a tap puts the caret after the suggestion instead of over it. Measured on a
// phone: the raise pad showing 4, tap, type "60" — the field reads 460. Add chips
// showing 200, tap, type "50" — 20050.
//
// Only number boxes. A name or a note is a thing you edit, and selecting all of
// it on focus would be the same mistake pointing the other way.
selectsOnFocus(field) {
if (!field || field.tagName !== "INPUT") return false;
const type = String(field.type || "").toLowerCase();
if (type === "number") return true;
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
},
// Would a wheel over this field change what it says?
//
// True only for a focused `<input type="number">`, which a browser treats as a
// spinbutton and lets the wheel drive. Every other way of driving one is deliberate
// — the arrow keys, the stepper arrows, typing — and all of them are left alone.
// Scrolling is the one nobody means: the dealer is reaching for another panel and
// the cursor happens to still be over a field they touched a moment ago.
//
// A predicate rather than a listener so the rule can be tested without a browser,
// the same way selectsOnFocus is. main.js does the blurring.
wheelWouldEdit(field, activeElement) {
// The tag check is belt and braces and known to be so: nothing but an <input>
// carries type="number", so the line below already excludes every textarea,
// select and button on the page. Deleting it passes every test in this file,
// which was checked rather than assumed. It stays for symmetry with
// selectsOnFocus above, not because it is load-bearing.
if (!field || field.tagName !== "INPUT") return false;
if (String(field.type || "").toLowerCase() !== "number") return false;
// An unfocused spinbutton ignores the wheel, so there is nothing to defend and
// blurring something that isn't focused would be a no-op with a side effect.
return activeElement === field;
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
@@ -76,6 +76,39 @@ const PotWizard = {
},
renderStep() {
this.renderStepInto();
this.markOverflow();
},
// Say when the list runs on past the bottom of the dialog.
//
// The body scrolls and the footer stays put, which is the right shape and was as far
// as it went: nothing on screen said there was anything below. Measured on a Galaxy
// S8, a three-way pot showed two names and cut the third off on a row boundary, just
// under a hairline that reads as the end of the list. A dealer would have ticked the
// two they could see. On the dialog that pays the table, a list that silently
// continues is not a small thing.
markOverflow() {
const body = document.getElementById("potWizardBody");
const modal = document.getElementById("potWizardModal");
if (!body || !modal) return;
const update = () => {
const below = body.scrollHeight - body.clientHeight - body.scrollTop;
modal.classList.toggle("potWizardMoreBelow", below > 1);
};
if (!this.watchingOverflow) {
body.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
this.watchingOverflow = true;
}
update();
// Again once the browser has laid the step out — on the first open the body has
// no height yet, and an answer taken then is always "nothing below".
requestAnimationFrame(update);
},
renderStepInto() {
// Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries);
while (contenders.length === 1) {
@@ -87,6 +120,11 @@ const PotWizard = {
const body = document.getElementById("potWizardBody");
body.innerHTML = "";
// The footer is a sibling of the body, outside the scroll — see the note in
// TexasHoldem.cshtml. Cleared alongside it so a step can never inherit the
// previous step's buttons.
const footer = this.footer();
footer.innerHTML = "";
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
body.appendChild(this.renderLog());
@@ -119,13 +157,25 @@ const PotWizard = {
const round = document.createElement("div");
round.className = "potWizardRound";
const showdown = this.readShowdown(contenders);
const decided = showdown && showdown.complete ? new Set(showdown.winners) : null;
const handById = new Map((showdown ? showdown.players : []).map(p => [p.id, p.hand]));
const prompt = document.createElement("p");
prompt.className = "potWizardPrompt";
prompt.innerHTML = this.log.length === 0
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
prompt.innerHTML = decided
// It has read the hands, so it stops asking a question it knows the answer
// to. The list stays — the dealer confirms, and a hand entered wrong is
// corrected by unticking rather than by starting over.
? `<strong>${Money.format(remaining)}</strong> in play.`
: this.log.length === 0
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
round.appendChild(prompt);
const verdict = this.renderVerdict(showdown, contenders);
if (verdict) round.appendChild(verdict);
const choices = document.createElement("div");
choices.className = "potWizardChoices";
contenders.forEach(c => {
@@ -135,6 +185,10 @@ const PotWizard = {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = c.id;
if (decided && decided.has(c.id)) {
checkbox.checked = true;
label.classList.add("chosen");
}
// The row is the target, not the box — so the row has to look chosen too.
checkbox.addEventListener("change", () => {
label.classList.toggle("chosen", checkbox.checked);
@@ -149,11 +203,25 @@ const PotWizard = {
name.textContent = this.nameById.get(c.id);
text.appendChild(name);
// How this hand was read, under the name — on every row rather than only
// the winning one, because a dealer checking the tool's answer is reading
// the losers' hands, and a row that says nothing is a row they have to work
// out for themselves. It takes the line the ceiling used to have; the
// ceiling moves out beside the name, which is where the phone layout had
// already put it to save exactly this line.
const read = handById.get(c.id);
if (read) {
const ranked = document.createElement("span");
ranked.className = "potWizardChoiceHand";
ranked.textContent = HandRank.describe(read);
text.appendChild(ranked);
}
label.appendChild(text);
const upTo = document.createElement("span");
upTo.className = "potWizardChoiceUpTo";
upTo.textContent = `up to ${Money.format(c.remaining)}`;
text.appendChild(upTo);
label.appendChild(text);
label.appendChild(upTo);
// Cards sit apart from the name rather than inside it, so a dealer reading
// a row across the table sees the hand, not a sentence.
@@ -182,7 +250,11 @@ const PotWizard = {
const confirmBtn = document.createElement("button");
confirmBtn.className = "holdemBtn holdemBtnAccent";
confirmBtn.textContent = "Confirm Winner(s)";
confirmBtn.textContent = decided
? (showdown.winners.length === 1
? `Pay ${this.nameById.get(showdown.winners[0])}`
: "Split the pot")
: "Confirm Winner(s)";
confirmBtn.addEventListener("click", () => {
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
if (selected.length === 0) return;
@@ -191,9 +263,91 @@ const PotWizard = {
this.entries = result.updatedEntries;
this.renderStep();
});
round.appendChild(confirmBtn);
body.appendChild(round);
footer.appendChild(confirmBtn);
},
// How the tool reads the hands still in for this round of the pot.
//
// Asked per round rather than once for the whole hand, because a side pot has its
// own contenders: the player who won the main pot is out of the question for the
// next one, and the best hand among who is left is a different answer.
//
// Only when the dealer asked to track the board — everything here is off by default
// and the wizard behaves exactly as it always has.
readShowdown(contenders) {
if (!this.state.trackBoard || typeof HandRank === "undefined") return null;
return HandRank.showdown(this.state.board, contenders.map(c => ({
id: c.id,
cards: this.cardsById.get(c.id) || [],
})));
},
// What the winners would actually be paid if this round were confirmed now.
//
// Worked out from the same function that does the paying rather than from a second
// one that agrees with it today — a preview that can drift from the payout is worse
// than no preview, because it is the number the dealer says out loud.
previewPayout(winnerIds) {
try {
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(winnerIds));
return result.perWinner || [];
} catch {
return [];
}
},
// "Alexandra wins $25 with a full house, kings full of threes."
//
// The whole point of the feature, in one line: who won and how much. It goes above
// the list rather than replacing it, because the dealer still confirms — the tool
// reads the cards, a person still says the pot is good.
renderVerdict(showdown, contenders) {
if (!showdown) return null;
const line = document.createElement("p");
line.className = "potWizardVerdict";
if (!showdown.boardComplete) {
line.classList.add("potWizardVerdictUnsure");
line.textContent = "The board isn't all five cards yet, so pick the winner yourself — or fill it in on the felt and this will read it.";
return line;
}
if (showdown.duplicated) {
line.classList.add("potWizardVerdictUnsure");
line.textContent = "The same card is in two places, so this can't be read. Check the cards on the felt.";
return line;
}
if (!showdown.complete) {
const missing = showdown.unknown.map(id => this.nameById.get(id)).filter(Boolean);
line.classList.add("potWizardVerdictUnsure");
line.textContent = missing.length
? `${this.listOf(missing)} ${missing.length === 1 ? "hasn't" : "haven't"} turned over, so who won is still yours to say.`
: "Not enough cards to read this one.";
return line;
}
const paid = this.previewPayout(showdown.winners);
const amount = paid.length ? Money.format(paid[0].amount) : "";
const names = showdown.winners.map(id => this.nameById.get(id)).filter(Boolean);
const hand = showdown.players.find(p => p.id === showdown.winners[0]);
const described = HandRank.phrase(hand && hand.hand);
line.classList.add("potWizardVerdictSure");
line.textContent = names.length === 1
? `${names[0]} wins ${amount} with ${described}.`
: `${this.listOf(names)} split it, ${amount} each, with ${described}.`;
return line;
},
listOf(names) {
if (names.length <= 1) return names[0] || "";
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
},
// The row of buttons that never scrolls away.
footer() {
return document.getElementById("potWizardFooter");
},
recordRound(result) {
@@ -232,6 +386,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,16 +412,43 @@ 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";
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
wrap.appendChild(empty);
} 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)}`;
// The amount is the answer to the arithmetic; the chips are the answer
// to the dealer's job. Only when the legend has been filled in — a
// table that never set up chip colours gets the number on its own,
// which is what it had before.
const chips = Money.formatChipBreakdown(amount, legend);
if (chips) {
const note = document.createElement("span");
note.className = "payoutChips";
note.textContent = chips;
li.appendChild(note);
}
list.appendChild(li);
}
wrap.appendChild(list);
@@ -262,13 +461,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);
@@ -287,12 +485,10 @@ const PotWizard = {
const note = document.createElement("p");
note.className = "fieldHint potWizardPendingNote";
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
wrap.appendChild(actions);
wrap.appendChild(note);
return wrap;
}
wrap.appendChild(actions);
this.footer().appendChild(actions);
return wrap;
},
};
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -6,10 +6,16 @@ const SeatMenu = {
hooks: null,
seatIndex: null,
mode: null,
open(state, hooks, seatIndex) {
this.state = state;
this.hooks = hooks;
this.seatIndex = seatIndex;
// Always the action list, never whatever the sheet was showing when it last
// closed — a seat menu that opens on a list of chairs is a menu that lost the
// dealer's place.
this.mode = null;
this.render();
document.getElementById("seatMenuOverlay").hidden = false;
},
@@ -41,6 +47,27 @@ const SeatMenu = {
run: () => this.hooks.toggleDealtIn(index),
});
// An empty chair with people waiting: the dealer taps the chair somebody is
// about to sit in, which is what their hand is doing anyway. Only the front of
// the queue is offered, for the same reason the panel only offers them — a list
// that isn't kept in order is just a pile of names.
const waiting = (this.state.waitingList || [])[0];
const emptyChair = !HoldemState.seatInPlay(player);
if (waiting && emptyChair) {
actions.push({
label: `Seat ${waiting.name}`,
className: "menuAccent",
run: () => this.hooks.seatWaiting(waiting.id, index),
});
}
// Leaving for the night, which is a different thing from sitting out a hand:
// the chips go back over the rail and the chair opens. Offered only for a seat
// that has somebody in it, since an empty chair has nobody to pay.
if (HoldemState.seatInPlay(player)) {
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
@@ -63,29 +90,81 @@ const SeatMenu = {
}
}
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
// generated from a touch screen — so on the device this tool is mostly used on,
// the "drag to swap seats" the hint advertised did nothing at all. This is the
// same move, by the route a thumb can actually take.
if (this.state.seats.length > 1) {
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
// The chairs this player could move to. Every other chair: swapping into an empty
// one is how a player moves, and swapping with an occupied one is two players
// changing places, which is the other half of the same job.
destinationsFor(index) {
return this.state.seats
.map((player, seat) => ({ player, seat }))
.filter(entry => entry.seat !== index);
},
openMovePicker() {
this.mode = "move";
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
const title = document.getElementById("seatMenuTitle");
const subtitle = document.getElementById("seatMenuSubtitle");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
if (this.mode === "move") {
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "seatMenuBtn";
// displayName falls back to "Seat 5" for a chair with nobody in it,
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
const named = (other.name || "").trim();
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
btn.addEventListener("click", () => {
const from = this.seatIndex;
this.close();
this.hooks.swapSeats(from, seat);
});
list.appendChild(btn);
});
return;
}
title.textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player);
const bits = [];
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
subtitle.textContent = bits.join(" · ");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
this.actionsFor(player, this.seatIndex).forEach(action => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `seatMenuBtn ${action.className || ""}`;
btn.textContent = action.label;
btn.addEventListener("click", () => {
// "Move to…" stays on the sheet and swaps it for the seat list; every
// other action is the end of the interaction.
if (action.label === "Move to…") {
action.run();
return;
}
this.close();
action.run();
});
@@ -0,0 +1,66 @@
// Who hands cash to whom at the end of the night.
//
// The standings panel already works out what everyone is up or down. It then stopped,
// which leaves the dealer with six signed numbers at 2am and the actual job still to
// do: turning them into payments. That's the step people get wrong — not the poker.
//
// Everything is done in cents. A settlement that has to come out exactly right is the
// last place to let a float land on 41.99999999.
const SettleUp = {
// Largest debt against largest win, repeatedly. It's greedy rather than optimal,
// and for this problem that's the right trade: finding the true minimum number of
// transfers is NP-hard, while this is guaranteed to need at most one payment fewer
// than there are people, and each payment it does emit clears somebody out of the
// list entirely. A dealer wants a short list they can read down, not a proof.
//
// Payers are the outer loop so that everything one person owes is printed together
// — the list is read out loud, one player at a time.
plan(standings) {
const owed = [];
const owing = [];
(standings || []).forEach(row => {
const cents = Money.toCents(row.cashPosition);
if (cents > 0) owed.push({ name: row.name, cents });
else if (cents < 0) owing.push({ name: row.name, cents: -cents });
});
owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
const payments = [];
let creditor = 0;
owing.forEach(payer => {
while (payer.cents > 0 && creditor < owed.length) {
const taker = owed[creditor];
const amount = Math.min(payer.cents, taker.cents);
payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
payer.cents -= amount;
taker.cents -= amount;
if (taker.cents === 0) creditor++;
}
});
// What's left over when the chips on the table don't match the cash that came
// in. It is never a rounding artifact — the arithmetic is integer cents — so it
// is always the thing the bank check is already warning about: a rebuy nobody
// wrote down, or a miscount. Reported rather than quietly spread across the
// payments, because inventing a transfer to make the columns agree is how a
// real person ends up short.
const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
return {
payments,
// Only one of these can be non-zero: either there are more chips than cash
// or less, never both.
shortBy: unpaid,
overBy: unspent,
balanced: unpaid === 0 && unspent === 0,
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SettleUp;
}
@@ -18,6 +18,23 @@ const Shortcuts = {
escape: "cancel",
},
// What each one does, in a dealer's words, for the legend in the sidebar.
//
// Lives beside KEYS because the two must not drift: F, C and R are printed on the
// buttons they drive, but A and U were on nothing at all — a dealer who learned
// the first three from the bar had no way to find out the other two existed. A
// shortcut with no line here is a shortcut nobody can find, and there's a test
// that says so.
LEGEND: [
{ keys: ["F"], action: "fold", what: "Fold" },
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
{ keys: ["U"], action: "undo", what: "Take back the last action" },
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
],
mount(handlers) {
this.handlers = handlers;
document.addEventListener("keydown", e => this.onKey(e));
@@ -40,6 +57,22 @@ const Shortcuts = {
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
},
// Enter and Space are how a keyboard presses the control it is already sitting on.
// Claiming them for "advance" didn't just block that press — it ran a different
// action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
// the next hand. Space on the same button worked, because "advance" happened to
// decline that time and the browser's own activation got through. A control that
// does something else every other press is worse than one that does nothing.
//
// Only these two keys are handed back. The letter shortcuts stay live with a
// button focused, because a button does nothing with "f" and the dealer's hand
// shouldn't have to leave them.
focusedControlOwnsKey(key) {
if (key !== "enter" && key !== " ") return false;
const el = document.activeElement;
return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
},
onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -47,6 +80,8 @@ const Shortcuts = {
const action = this.KEYS[key];
if (!action) return;
if (this.focusedControlOwnsKey(key)) return;
// Escape is the exception: it should still close the raise pad even if focus
// happens to be sitting in its amount field.
if (this.isTyping() && !(action === "cancel")) return;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
// Keeps the screen on while a hand is live.
//
// The dealer's phone spends the night face-up on the felt with nine people round it,
// and the one thing it reliably does between the flop and the turn is dim, then lock.
// Every hand, all night. A dozen rounds of work have gone into making the next step
// readable at a glance, and none of it survives a black screen and a passcode with
// four people waiting to know whether the bet is called.
//
// Held only while betting is actually open. A phone left on the table through a break,
// a re-buy, or the twenty minutes where everyone is arguing about pizza sleeps exactly
// like any other phone — which is the difference between a tool that respects a battery
// and one a dealer stops trusting with their evening.
const WakeLock = {
// The browser's promise to stay awake. Null whenever we aren't holding one, which
// includes every moment the browser has taken it back on us — see `start()`.
sentinel: null,
// What the last sync decided we want. Kept separate from `sentinel` because
// requesting one is asynchronous: a hand can be dealt and folded out inside the
// await, and the answer to "should we still be holding this" has to be the current
// one rather than the one that was true when the request went out.
wanted: false,
// One request at a time. `sync()` runs off renderAll, which fires many times a
// second during a busy street; without this, two overlapping requests both get
// past the "already holding one?" check and the second sentinel leaks — the screen
// then stays awake after the hand ends, with nothing left holding a reference to
// turn it off.
pending: false,
// Whether the dealer has been told this is happening. Once per page load, not once
// per hand: the same toast at the top of all forty hands of a session would be the
// single most annoying thing the tool does.
told: false,
// Set by main.js so the toast's "Turn off" can actually turn it off.
hooks: null,
supported() {
return typeof navigator !== "undefined" && "wakeLock" in navigator;
},
// The whole decision, with no browser in it, so it can be tested as what it is:
// a rule about when a poker table needs the screen.
shouldHold(state) {
if (!state) return false;
if (state.keepScreenAwake === false) return false;
return state.bettingOpen === true;
},
// Called after anything that could start or end a hand. Safe to call constantly.
sync(state) {
this.wanted = this.supported() && this.shouldHold(state);
if (this.wanted) this.acquire();
else this.release();
},
async acquire() {
if (this.sentinel || this.pending) return;
this.pending = true;
try {
const sentinel = await navigator.wakeLock.request("screen");
// The hand can have ended while that was in flight. Letting this one go
// immediately is the only correct answer; holding it "just until the next
// sync" is how a phone stays lit on an empty table.
if (!this.wanted) {
try { await sentinel.release(); } catch { /* already gone */ }
return;
}
this.sentinel = sentinel;
// The browser releases this itself when the tab is hidden, and tells us so.
// Without this listener our `sentinel` would be a stale object and the
// re-acquire in `start()` would decline to do anything.
sentinel.addEventListener("release", () => {
if (this.sentinel === sentinel) this.sentinel = null;
});
this.tell();
} catch {
// Battery saver, a hidden tab, a browser that simply says no. All of these
// are the browser doing its job, and none of them is worth an error at a
// poker table — the tool works exactly as it did before, the screen just
// dims the way the phone's own settings say it should.
} finally {
this.pending = false;
}
},
async release() {
const sentinel = this.sentinel;
this.sentinel = null;
if (!sentinel) return;
try { await sentinel.release(); } catch { /* already released */ }
},
// Said once, at the moment it first becomes true, with the way out attached.
//
// The alternative was a permanent indicator in the header, and the header is the
// one part of this page that has been fought over for crowding more than any other.
// A dealer needs to know their phone is behaving unusually exactly once; after that
// it is a setting, and settings live in the sidebar.
tell() {
if (this.told) return;
this.told = true;
if (typeof Toast === "undefined") return;
Toast.show("Keeping the screen on while a hand is live.", {
actionLabel: "Turn off",
onAction: () => { if (this.hooks && this.hooks.turnOff) this.hooks.turnOff(); },
});
},
// A screen wake lock is not something you take once. The browser drops it whenever
// the document stops being visible — switching apps to check a rule, the phone
// locking on its own before the first hand was dealt, a notification pulled down —
// and it never comes back on its own. A tool that requests it at the start of the
// hand and assumes it still has it is a tool that works until the first interruption.
start(getState) {
if (!this.supported()) return;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") return;
this.sync(getState());
});
},
};
if (typeof module !== "undefined" && module.exports) module.exports = WakeLock;
+978 -6
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
// Saying who the blind is, before saying "cards".
//
// A dealer sets the table up out loud — button here, she's the small blind, he's the
// big blind — and only then deals. The seat menu offers "Make small blind" for exactly
// that, and it was answering by dealing the hand: betting opened, two stacks docked,
// the action bar up and waiting on the seat after the big blind. The dealer had said
// where the blinds go, and the tool heard "deal".
//
// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in
// this hand except the blinds" — true of a hand one second old, and equally true of a
// table in setup with no cards out and nothing committed. Those two need telling apart,
// which is what `blindsArePosted` is for.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
describe("before the deal", () => {
test("the blinds are not down, however much the table looks ready", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
assert.equal(state.bettingOpen, false);
// The old condition, on its own, says yes here — this is the whole bug.
assert.equal(Betting.onlyBlindsIn(state), true);
assert.equal(Betting.blindsArePosted(state), false);
});
test("a table straight out of setup, with nobody seated, is the same answer", () => {
assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false);
});
test("re-posting would have dealt the hand, which is why it is guarded", () => {
// Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in
// setup is the deal, and nothing about naming a blind should reach it.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
Betting.repostBlinds(state);
assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up");
assert.notEqual(state.actingSeatIndex, null);
});
test("naming a blind moves a badge and no chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
// What the seat menu does: pin the override, repost only if the blinds are down.
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt");
assert.equal(state.actingSeatIndex, null, "nobody is being asked to act");
assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]);
assert.equal(chipsOnTable(state), before);
// And the badges say what the dealer just said.
const blinds = HoldemState.getBlindSeats(state);
assert.equal(blinds.sbIndex, 3);
assert.equal(blinds.bbIndex, 0);
});
test("and the hand that follows posts from where they were aimed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
});
test("giving the button away is a marker moving too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
const willRepost = Betting.blindsArePosted(state);
state.dealerSeatIndex = 2;
if (willRepost) Betting.repostBlinds(state);
assert.equal(willRepost, false);
assert.equal(state.bettingOpen, false);
assert.equal(chipsOnTable(state), before);
});
});
describe("once the hand is dealt", () => {
test("the blinds are down, and re-aiming them moves real chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(Betting.blindsArePosted(state), true);
assert.equal(Betting.committed(state.seats[1]), 1);
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back");
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle");
});
test("but not once somebody has answered them", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, state.actingSeatIndex);
assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now");
});
test("nor on a later street", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
state.street = "flop";
assert.equal(Betting.blindsArePosted(state), false);
});
test("and a misdealt hand goes back to being a table in setup", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.takeHandBack(state);
assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again");
});
test("as does a hand that has been paid out", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.sweepToHandTotals(state);
HoldemState.startNextHand(state);
assert.equal(Betting.blindsArePosted(state), false);
});
});
+173
View File
@@ -0,0 +1,173 @@
// Who the felt marks "ALL IN".
//
// The mark's whole value is that a dealer can scan the ring and see who can still be
// bet at, so the test that matters most is the last one: the seats it marks are
// exactly the seats the betting round refuses to ask for another chip.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// The declarations inside one rule, by its exact selector.
const ruleBody = selector => {
const at = CSS.indexOf(`\n${selector} {`);
if (at < 0) return null;
const open = CSS.indexOf("{", at);
return CSS.slice(open + 1, CSS.indexOf("}", open));
};
test("a player who moved all-in is marked", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.equal(Betting.isAllIn(state, state.seats[3]), true);
assert.equal(state.seats[3].stack, 0);
});
test("a player with chips behind is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
assert.equal(Betting.isAllIn(state, state.seats[0]), false);
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("a busted player who was dealt in but has put nothing in is not", () => {
// An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
// in. Calling that "ALL IN" on the felt would be a lie about a live pot.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("a folded player with an empty stack is not", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[1].committed = 5;
state.seats[1].stack = 0;
state.seats[1].folded = true;
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("an empty chair is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(Betting.isAllIn(state, state.seats[4]), false);
});
test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.defaultStack = 200;
state.seats[2].stack = null;
state.seats[2].committed = 10;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("the seats marked are exactly the ones the action passes over", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[1].stack = 8;
state.seats[3].stack = 30;
Betting.beginHand(state);
// Everyone in, two of them for less than the others can cover.
for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
const marked = state.seats
.map((player, index) => (Betting.isAllIn(state, player) ? index : null))
.filter(index => index !== null);
const skipped = state.seats
.map((player, index) =>
player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
.filter(index => index !== null);
assert.deepEqual(marked, skipped);
assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
});
test("nobody is marked once the next hand starts", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 5;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
// The short stack got there by winning nothing; give them chips back the way a
// dealer would before the next deal.
HoldemState.startNextHand(state);
state.seats[2].stack = 50;
assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
});
test("marking nobody doesn't move a chip", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
const before = chipsOnTable(state);
state.seats.forEach(player => Betting.isAllIn(state, player));
assert.equal(chipsOnTable(state), before);
});
// ---- And the shape of the mark, which is the felt's problem ------------------
//
// "ALL IN" is the only thing that ever lands on the stack line with a space in it, and
// a space is somewhere to break. Measured at 360x640 with twelve players all-in, it
// wanted 36px and had 34: it broke onto a second line, and an 11px line became 22.
//
// That is not a typography complaint. The puck went from 55px tall to 66, and puck
// height is what the seat ring is short of — at the ends of an ellipse the seats are
// separated vertically, so 11px taller is 11px further into the seat next door. The
// layout search then tried every width it has, found none that cleared, and fell back
// to the narrowest, which is also the one that wraps hardest.
//
// So the rule every design system gives a badge applies here too: one line, always.
describe("the mark is one line, because the ring cannot afford two", () => {
test("a stack line can never wrap", () => {
const body = ruleBody(".seatPuckStack");
assert.ok(body, "the .seatPuckStack rule should exist");
assert.match(body, /white-space:\s*nowrap/,
"the stack line must not be allowed to wrap — a second line is 11px the ring hasn't got");
});
test("and on the tightest pucks it gives up its letter-spacing to fit", () => {
// Not wrapping alone only converts a wrap into a spill: a line that cannot wrap
// and still doesn't fit paints over the seat beside it instead. The spacing is
// 3.6px of the 36 it wanted, and it is the one of the mark's three signals that
// can go — the warm colour and the bold weight stay.
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
const tight = ruleBody(".seatsTight .seatPuckStack.seatPuckAllIn");
assert.ok(base, "the base all-in rule should exist");
assert.ok(tight, "the tight-tier all-in rule should exist");
const spacingOf = body => {
const match = body.match(/letter-spacing:\s*([\d.]+)em/);
return match ? Number(match[1]) : null;
};
const wide = spacingOf(base);
const narrow = spacingOf(tight);
assert.ok(wide !== null && narrow !== null, "both rules should set letter-spacing in em");
assert.ok(narrow < wide,
`the tight tier should space the mark more tightly than the ordinary one (${narrow} vs ${wide})`);
// The arithmetic that has to hold for it to fit at all: six characters at 10px
// with 4px of padding either side and a 1px border, inside a 44px puck.
const CONTENT_PX = 44 - 2 * 4 - 2 * 1;
const inkAt = spacing => 32.4 + spacing * 10 * "ALL IN".length;
assert.ok(inkAt(narrow) <= CONTENT_PX,
`"ALL IN" must fit the narrowest puck: ${inkAt(narrow).toFixed(1)}px into ${CONTENT_PX}px`);
assert.ok(inkAt(wide) > CONTENT_PX,
"the ordinary spacing is what didn't fit — if it does now, this test is measuring the wrong thing");
});
test("the two signals that survive are the ones doing the work", () => {
// The spacing exists so "ALL IN" doesn't read as money at a glance. Dropping it
// on the narrow pucks is only safe while the colour and the weight remain, so
// they are pinned here rather than left to be tidied away later.
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
assert.match(base, /color:\s*#ffcf6b/i, "the warm colour separates it from a grey stack");
assert.match(base, /font-weight:\s*700/, "and the weight does the rest");
});
});
+135
View File
@@ -0,0 +1,135 @@
// What the tool says out loud between taps.
//
// The action bar carried `aria-live="polite"` on the element whose entire contents
// render() rebuilds every action, so a screen reader read the whole bar after every
// single tap. Measured, after one:
//
// "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
// Fold Call $10 Raise"
//
// Nothing in that is wrong. It is just unusable at the speed a hand is dealt, and it
// is the documented anti-pattern: a live region has to be a stable element whose text
// changes, not a container that gets replaced.
//
// What a dealer running this by ear needs is who is on the clock and what it costs
// them. These are the sentences.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar, Betting, HoldemState, makeTable } = require("./load");
const barOn = (state) => {
ActionBar.state = state;
ActionBar.lastAnnouncement = undefined;
return ActionBar;
};
describe("before the cards are out", () => {
test("a table that could be dealt says so", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
assert.equal(barOn(state).announcement(), "Ready to deal.");
});
test("and an empty table says what it is, rather than nothing", () => {
// This used to announce "" — silence being the honest answer when the bar was
// hidden on a table nobody was sitting at. The bar isn't hidden any more: an
// empty table is the one state a dealer meets before they know how any of this
// works, so it says so and offers the next step.
const state = HoldemState.createDefault();
assert.equal(barOn(state).announcement(), "Nobody's at the table yet.");
});
test("and a table with one named player has moved on from that", () => {
const state = HoldemState.createDefault();
state.seats[0].name = "Ann";
assert.notEqual(barOn(state).announcement(), "Nobody's at the table yet.");
});
});
describe("while a hand is running", () => {
const dealt = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
state.seats.forEach((p, i) => { p.name = ["Ann", "Ben", "Cal", "Dee"][i] || ""; });
Betting.beginHand(state);
return state;
};
test("names who is on the clock, what it costs, and the pot", () => {
const state = dealt();
const said = barOn(state).announcement();
assert.match(said, /to act/);
assert.match(said, /\$2 to call/);
assert.match(said, /Pot \$3\./);
});
test("it is the seat that is actually acting, not the one after it", () => {
const state = dealt();
const name = HoldemState.displayName(state.seats[state.actingSeatIndex], state.actingSeatIndex);
assert.ok(barOn(state).announcement().startsWith(name + " to act"));
});
test("says checked to them rather than $0 to call", () => {
// A number nobody owes is a number a dealer has to stop and parse.
const state = dealt();
state.currentBet = 0;
assert.match(barOn(state).announcement(), /checked to them/);
assert.doesNotMatch(barOn(state).announcement(), /\$0 to call/);
});
test("a street with nobody left to act says the street is done", () => {
const state = dealt();
state.actingSeatIndex = null;
state.street = "flop";
assert.equal(barOn(state).announcement(), "Flop betting is done.");
});
test("and a finished hand points at the pot", () => {
const state = dealt();
state.seats.forEach((p, i) => { if (i !== 0) p.folded = true; });
assert.equal(Betting.isHandOver(state), true);
assert.equal(barOn(state).announcement(), "Hand over. Resolve the pot.");
});
});
describe("how often it speaks", () => {
// A live region that is handed the same sentence twice reads it out twice, and
// render() runs for a great many reasons that have nothing to do with whose turn
// it is — a resize, a panel opening, a note being typed.
const stubTarget = () => {
const el = { textContent: "" };
global.document = { getElementById: id => (id === "actionAnnouncer" ? el : null) };
return el;
};
test("says nothing new when nothing changed", () => {
const el = stubTarget();
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
Betting.beginHand(state);
const bar = barOn(state);
bar.announce();
const first = el.textContent;
assert.ok(first.length > 0);
el.textContent = "CLEARED"; // if announce() writes again, this comes back
bar.announce();
assert.equal(el.textContent, "CLEARED", "the same sentence was not written twice");
delete global.document;
});
test("but does when the action moves", () => {
const el = stubTarget();
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
Betting.beginHand(state);
const bar = barOn(state);
bar.announce();
const first = el.textContent;
Betting.call(state, state.actingSeatIndex);
bar.announce();
assert.notEqual(el.textContent, first);
assert.match(el.textContent, /to act/);
delete global.document;
});
});
+98
View File
@@ -0,0 +1,98 @@
// Perceptual contrast (APCA), and how much of it each piece of text needs.
//
// The audit already had a contrast check and it passed, which is why this exists. That
// check measures WCAG 2 contrast ratio, and WCAG 2's own authors say it is the wrong
// instrument for this page: the ratio is a luminance gap, and a luminance gap
// systematically *overstates* readability once one of the two colours is near black.
// This table's page is #141414 and its panels are darker still, so every reading it
// takes is on the side of the algorithm where it is least reliable. Text can clear
// 4.5:1 and still read as a smudge.
//
// APCA asks the other question — can text of *this size and weight* be read on this
// background — and answers it on a scale where a given number means the same
// readability at either polarity. It replaces nothing: the WCAG check stays, because
// that is the number an accessibility audit will ask for. This is the one that says
// whether a dealer can read it in a dim room.
//
// Constants are 0.98G-4g, and the implementation is checked against the eight
// published reference pairs in apcaMath.test.js. An unverified contrast checker is
// worse than none, because it launders a guess into a number.
const MAIN_TRC = 2.4;
const R_CO = 0.2126729, G_CO = 0.7151522, B_CO = 0.0721750;
const NORM_BG = 0.56, NORM_TXT = 0.57, REV_TXT = 0.62, REV_BG = 0.65;
const BLK_THRS = 0.022, BLK_CLMP = 1.414;
const SCALE_BOW = 1.14, SCALE_WOB = 1.14;
const LO_BOW_OFFSET = 0.027, LO_WOB_OFFSET = 0.027;
const DELTA_Y_MIN = 0.0005, LO_CLIP = 0.001;
// Screen luminance. Note this is a straight exponent, not the sRGB piecewise curve —
// APCA specifies the simple 2.4 power and the two are not interchangeable here.
function sRGBtoY(rgb) {
const s = c => Math.pow(c / 255.0, MAIN_TRC);
return R_CO * s(rgb[0]) + G_CO * s(rgb[1]) + B_CO * s(rgb[2]);
}
// Signed: positive is dark text on light, negative is light text on dark. The sign
// carries the polarity, and polarity is not cosmetic — the same luminance pair reads
// differently each way round, which is the whole reason this exists.
function contrast(txtY, bgY) {
txtY = txtY > BLK_THRS ? txtY : txtY + Math.pow(BLK_THRS - txtY, BLK_CLMP);
bgY = bgY > BLK_THRS ? bgY : bgY + Math.pow(BLK_THRS - bgY, BLK_CLMP);
if (Math.abs(bgY - txtY) < DELTA_Y_MIN) return 0.0;
let sapc, out;
if (bgY > txtY) {
sapc = (Math.pow(bgY, NORM_BG) - Math.pow(txtY, NORM_TXT)) * SCALE_BOW;
out = sapc < LO_CLIP ? 0.0 : sapc - LO_BOW_OFFSET;
} else {
sapc = (Math.pow(bgY, REV_BG) - Math.pow(txtY, REV_TXT)) * SCALE_WOB;
out = sapc > -LO_CLIP ? 0.0 : sapc + LO_WOB_OFFSET;
}
return out * 100;
}
function lc(textRgb, bgRgb) {
return contrast(sRGBtoY(textRgb), sRGBtoY(bgRgb));
}
// How much contrast this text needs, from the published APCA levels:
//
// Lc 90 fluent body text; and the floor for anything at 14px/400 or 12px/400
// Lc 75 the minimum for a column of body text (18px/400, 16px/500, 14px/700)
// Lc 60 the minimum for content text that isn't a column or block
// Lc 45 larger, heavier text — headlines at 36px/400 or 24px/700
// Lc 30 the absolute floor, and only for genuinely spot-read text
//
// Interpolating between those anchors rather than inventing a curve. Weight is folded
// in as an effective size because that is how the published table behaves: bolder
// strokes buy roughly the same readability as bigger ones, and 14px/700 sitting at the
// same Lc 75 as 18px/400 is the anchor that fixes the exchange rate.
function requiredLc(px, weight = 400) {
const heavier = Math.max(0, (Number(weight) || 400) - 400) / 300; // 400..700 -> 0..1
const effective = px * (1 + 0.28 * heavier); // 14px/700 ~= 18px/400
const anchors = [
[12, 90],
[14, 90],
[15, 82],
[18, 75],
[24, 60],
[36, 45],
];
if (effective <= anchors[0][0]) return anchors[0][1];
const last = anchors[anchors.length - 1];
if (effective >= last[0]) return last[1];
for (let i = 1; i < anchors.length; i++) {
const [x0, y0] = anchors[i - 1];
const [x1, y1] = anchors[i];
if (effective <= x1) {
const t = (effective - x0) / (x1 - x0);
return y0 + t * (y1 - y0);
}
}
return last[1];
}
module.exports = { sRGBtoY, contrast, lc, requiredLc };
+141
View File
@@ -0,0 +1,141 @@
// The contrast arithmetic, checked against the numbers its authors publish.
//
// A contrast checker that is wrong is worse than not having one, because it launders a
// guess into a number and then a stylesheet gets tuned to it. The eight pairs below are
// the reference values published with APCA 0.98G-4g; if the implementation drifts from
// them by so much as a rounding change, these fail and nothing downstream is believed.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { lc, contrast, sRGBtoY, requiredLc } = require("./apca");
const hex = h => {
h = h.replace("#", "");
if (h.length === 3) h = h.split("").map(c => c + c).join("");
return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
};
describe("the published reference pairs", () => {
// Text first, background second, exactly as the reference table lists them.
const CASES = [
["#888", "#fff", 63.056469930209424],
["#fff", "#888", -68.54146436644962],
["#000", "#aaa", 58.146262578561334],
["#aaa", "#000", -56.24113336839742],
["#123", "#def", 91.66830811481631],
["#def", "#123", -93.06770049484275],
["#123", "#444", 8.32326136957393],
["#444", "#123", -7.526878460278154],
];
CASES.forEach(([text, bg, expected]) => {
test(`${text} on ${bg} is Lc ${expected.toFixed(2)}`, () => {
assert.ok(Math.abs(lc(hex(text), hex(bg)) - expected) < 1e-9,
`got ${lc(hex(text), hex(bg))}, want ${expected}`);
});
});
});
describe("polarity", () => {
test("is signed, and the sign is not decoration", () => {
// Dark text on light is positive, light text on dark negative. The two are not
// mirror images — swapping them changes the magnitude as well as the sign,
// which is the entire reason this is used instead of a ratio.
const dark = lc(hex("#888"), hex("#fff"));
const light = lc(hex("#fff"), hex("#888"));
assert.ok(dark > 0 && light < 0);
assert.ok(Math.abs(Math.abs(dark) - Math.abs(light)) > 5,
"the same pair reads differently each way round");
});
test("a colour on itself is nothing at all", () => {
assert.equal(lc(hex("#2c2c2c"), hex("#2c2c2c")), 0);
});
test("and so is a difference too small to see", () => {
// Below deltaYmin the answer is zero rather than a tiny number, so a rounding
// artefact can never be reported as contrast.
assert.equal(lc([44, 44, 44], [44, 44, 45]), 0);
});
});
describe("luminance", () => {
test("is the straight 2.4 power, not the sRGB piecewise curve", () => {
// Worth pinning: the two are close enough to look right and different enough to
// move a borderline reading. Mid grey under the piecewise curve is ~0.2159.
assert.ok(Math.abs(sRGBtoY([128, 128, 128]) - Math.pow(128 / 255, 2.4)) < 1e-6);
});
test("white is as close to 1 as the published coefficients get", () => {
// 1.0000001, not 1. The three coefficients are given to seven places and sum to
// a hair over one, and that is the specification rather than a mistake to
// correct — the reference pairs above only come out exact with these values.
// Asserted loosely on purpose: tightening this to 1e-12 fails on correct code.
assert.ok(Math.abs(sRGBtoY([255, 255, 255]) - 1) < 1e-6);
assert.equal(sRGBtoY([0, 0, 0]), 0);
});
test("green carries most of it", () => {
// Not arbitrary: it's why gold on green was the worst pairing on the felt.
assert.ok(sRGBtoY([0, 255, 0]) > sRGBtoY([255, 0, 0]) + sRGBtoY([0, 0, 255]));
});
test("contrast() takes luminances, lc() takes colours", () => {
assert.equal(contrast(sRGBtoY(hex("#888")), sRGBtoY(hex("#fff"))), lc(hex("#888"), hex("#fff")));
});
});
describe("how much contrast a given piece of text needs", () => {
test("the published anchors land where they should", () => {
assert.equal(Math.round(requiredLc(12, 400)), 90);
assert.equal(Math.round(requiredLc(14, 400)), 90);
assert.equal(Math.round(requiredLc(18, 400)), 75);
assert.equal(Math.round(requiredLc(24, 400)), 60);
assert.equal(Math.round(requiredLc(36, 400)), 45);
});
test("smaller text asks for more, never less", () => {
for (let px = 9; px < 40; px++) {
assert.ok(requiredLc(px, 400) >= requiredLc(px + 1, 400),
`${px}px should ask for at least as much as ${px + 1}px`);
}
});
test("weight buys size, at the rate the published table sets", () => {
// 14px/700 and 18px/400 sit at the same Lc 75 in the table, and that pair is
// what fixes the exchange rate between weight and size here.
assert.ok(Math.abs(requiredLc(14, 700) - requiredLc(18, 400)) < 3);
assert.ok(requiredLc(14, 700) < requiredLc(14, 400), "bold asks for less than regular");
});
test("it never runs off either end", () => {
assert.equal(requiredLc(4, 400), 90);
assert.equal(requiredLc(200, 900), 45);
});
});
describe("what the page's own colours measure", () => {
// These are the readings the round was built on. Pinned so that a future change to
// the tokens has to look at them rather than move them by accident.
const PANEL = hex("#1a1a1a");
test("the greys these tokens replaced were below the floor", () => {
assert.ok(Math.abs(lc(hex("#9a9a9a"), PANEL)) < 50, "the old muted grey");
assert.ok(Math.abs(lc(hex("#8a8a8a"), PANEL)) < 40, "the old dim grey");
});
test("and the ones that replaced them clear it", () => {
assert.ok(Math.abs(lc(hex("#c8c8c8"), PANEL)) >= 60, "muted");
assert.ok(Math.abs(lc(hex("#b4b4b4"), PANEL)) >= 58, "dim");
});
test("the three tiers are still three tiers", () => {
// The worry with lifting muted and dim is that they collapse into --text and the
// page flattens. They don't: the spread is wider than it was.
const text = Math.abs(lc(hex("#eeeeee"), PANEL));
const muted = Math.abs(lc(hex("#c8c8c8"), PANEL));
const dim = Math.abs(lc(hex("#b4b4b4"), PANEL));
assert.ok(text > muted && muted > dim, "still ordered");
assert.ok(text - dim > 30, "and still a real spread");
});
});
+175
View File
@@ -0,0 +1,175 @@
// Chips the pot was priced with that nobody actually paid.
//
// There are two ways a contribution gets into this tool. Tapping the action moves
// chips: the bet leaves the stack, sits in front of the player, and is swept into their
// hand total at the end of the street. The other is the "without live betting" panel —
// a base pot amount, or chips counted out on a seat — where the dealer is recording a
// hand they are watching rather than driving, and nothing ever leaves a stack.
//
// The pot wizard prices both the same way and awarding it credits the winner, so in
// that second mode the table simply grew.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
const basePotTable = (base = 50, stack = 200) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 4);
state.basePotAmount = base;
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].stack = stack;
state.seats[i].buyIns = stack;
});
return state;
};
const pricedPot = (state) => state.seats.reduce(
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
describe("what nobody paid", () => {
test("comes off the stacks that owe it", () => {
const state = basePotTable();
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 150);
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
});
test("and lands where the rest of the tool looks for chips in the middle", () => {
const state = basePotTable();
Betting.collectAssumedContributions(state);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
assert.equal(HoldemState.unresolvedContributions(state), 150);
});
test("chips counted out on a seat are money too", () => {
const state = basePotTable(0);
state.seats[0].chipCounts = { red: 4 }; // four $5 chips
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 20);
assert.equal(state.seats[0].stack, 180);
});
});
describe("what has already been paid", () => {
test("a live bet is left exactly alone", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
Betting.beginHand(state);
const before = state.seats.map(p => Betting.stackOf(state, p));
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 0, "the blinds are already off their stacks");
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("nor is a swept street taken a second time", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
Betting.beginHand(state);
Betting.sweepToHandTotals(state);
const before = state.seats.map(p => Betting.stackOf(state, p));
assert.equal(Betting.collectAssumedContributions(state).total, 0);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("a base pot on top of a live bet only collects the difference", () => {
// The base pot says "assume everyone is in for fifty". Somebody who has already
// put twenty in owes thirty of it, not fifty.
const state = basePotTable(50);
state.seats[0].committed = 20;
state.seats[0].stack = 180;
const collected = Betting.collectAssumedContributions(state);
const ann = collected.taken.find(row => row.name === "Ann");
assert.equal(ann.amount, 30);
assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
});
});
describe("what it will not do", () => {
test("take more than a player is holding", () => {
const state = basePotTable(50);
state.seats[1].stack = 20;
Betting.collectAssumedContributions(state);
assert.equal(state.seats[1].stack, 0, "all of it, and no further");
assert.equal(state.seats[1].amountOverride, 20);
});
test("touch an empty chair", () => {
const state = basePotTable();
const before = { ...state.seats[3] };
Betting.collectAssumedContributions(state);
assert.equal(state.seats[3].stack, before.stack);
assert.equal(state.seats[3].amountOverride, before.amountOverride);
});
test("or a seat that isn't in the hand", () => {
// The base pot stands in for "everyone put the same in", and somebody sitting
// out did not.
const state = basePotTable();
state.seats[2].inHand = false;
const collected = Betting.collectAssumedContributions(state);
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
assert.equal(state.seats[2].stack, 200);
});
});
describe("the whole hand", () => {
test("conserves the chips on the table, which it did not before", () => {
const state = basePotTable();
const total = chipsOnTable(state);
const pot = pricedPot(state);
Betting.collectAssumedContributions(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("and the bank check stays balanced all the way through", () => {
const state = basePotTable();
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.collectAssumedContributions(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
HoldemState.startNextHand(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("a live-betting hand is unchanged by any of this", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
const total = chipsOnTable(state);
Betting.beginHand(state);
[3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
Betting.sweepToHandTotals(state);
const pot = HoldemState.unresolvedContributions(state);
Betting.collectAssumedContributions(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), total);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("collecting twice takes nothing the second time", () => {
// The pot wizard can be opened, cancelled and opened again.
const state = basePotTable();
Betting.collectAssumedContributions(state);
const stacks = state.seats.map(p => p.stack);
const again = Betting.collectAssumedContributions(state);
assert.equal(again.total, 0);
assert.deepEqual(state.seats.map(p => p.stack), stacks);
});
});
+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`);
});
});
});
+473
View File
@@ -0,0 +1,473 @@
// Where a player's bet sits on the felt.
//
// The chips were the last thing on the table nobody had measured. They were laid out on
// a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the
// seat ring was itself fixed — and the choice between that ring and clinging to the
// puck was made by asking how wide the *window* was. Neither of those is a question
// about the felt. A phone held sideways is 844px across and gives the felt 117px of
// height: it passed the width test, took the ring, and every chip on it was painted
// behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a
// chip under a puck is not dimmed or half-hidden — it is gone, and with it the only
// number saying what that player has in.
//
// Everything below is geometry, so it runs without a browser. The felts are the ones
// measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
const RADII = { rx: 44, ry: 42 };
const PUCK = { width: 96, height: 64 };
const CHIP = { width: 44, height: 22 };
const felt = (width, height) => ({ width, height });
const spacingOn = (box, puck = PUCK, chip = CHIP) =>
({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX });
// A chip's centre in pixels on a given felt.
const chipPixels = (index, count, box, spacing, radii = RADII) => {
const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing);
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
};
const seatPixels = (index, count, box, radii = RADII) => {
const pos = Render.seatPosition(index, count, box.width / box.height, radii);
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
};
// Two axis-aligned boxes, by their centres. The same test the seats settle among
// themselves — see seatFootprint.test.js — because it is the same question.
const boxesOverlap = (a, b, sizeA, sizeB) =>
Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 &&
Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5;
describe("reaching the edge of a box", () => {
test("straight out the side is half the width, straight up half the height", () => {
assert.equal(Render.edgeReach(96, 64, 1, 0), 48);
assert.equal(Render.edgeReach(96, 64, 0, -1), 32);
assert.equal(Render.edgeReach(96, 64, -1, 0), 48);
});
test("a corner is whichever edge the ray meets first", () => {
// 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the
// ray leaves through one of those. A single radius — half the diagonal, 57.7 —
// would put a chip 25px further out than it needs to be, which on a 117px felt
// is the difference between clear of the puck and off the table.
const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2);
assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`);
assert.ok(diagonal < 48, "and never further than the wider half-edge");
});
test("a square is the same in every direction it is asked about", () => {
const straight = Render.edgeReach(50, 50, 0, 1);
assert.equal(Render.edgeReach(50, 50, 1, 0), straight);
});
});
describe("getting one box clear of another", () => {
const PUCK96 = { width: 96, height: 88 };
const CHIP50 = { width: 50, height: 21 };
// Travel `t` along `u` from the puck's centre, then ask whether the two boxes are
// apart. This is the question clearanceReach is supposed to answer, asked the long
// way round so the answer can be checked rather than restated.
const apart = (t, ux, uy) =>
Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 ||
Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9;
test("straight up and down it is what adding the two half-heights gives", () => {
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2);
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2);
});
test("on a diagonal it is more than the two reaches added together", () => {
// The bug this replaced. Two axis-aligned boxes are clear when they are apart on
// one axis, and the travel that achieves that is the reach of the box you get by
// adding their widths and their heights — which is never less, and on a diagonal
// is more, than adding the two boxes' own reaches. At 768x1024 the difference was
// 11px, and 11px is how far inside its own player's puck the chip came to rest.
const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]];
let shortfalls = 0;
dirs.forEach(([ux, uy]) => {
const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) +
Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy);
const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`);
if (proper <= summed + 1e-9) return;
// Where they differ, the old answer was not merely smaller — it was wrong:
// travelling that far leaves the two boxes still on top of each other.
shortfalls++;
assert.ok(!apart(summed, ux, uy),
`${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`);
});
assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these");
});
test("and it is exactly enough — a hair less is not", () => {
const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]];
dirs.forEach(([ux, uy]) => {
const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`);
assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`);
});
});
});
describe("a chip on the ring", () => {
test("with nothing measured it keeps the proportions the ring always used", () => {
// The unit suite and the very first render both call it this way. 27/44 and
// 25/42 of the seat's own reach, so the chips move with the ring rather than
// with the felt.
const seat = Render.seatPosition(3, 9, 1.4, RADII);
const chip = Render.betPosition(3, 9, 1.4, RADII, null);
assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9);
assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9);
});
test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => {
const box = felt(560, 400);
const spacing = spacingOn(box);
for (let i = 0; i < 9; i++) {
const seat = seatPixels(i, 9, box);
const chip = chipPixels(i, 9, box, spacing);
assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP),
`seat ${i + 1}'s own chip is under its own puck`);
}
});
test("and never wanders into the pot badge in the middle", () => {
// The floor: 38% of the way out. Without it a chip walked in past a puck that
// is 96px wide on a felt whose reach is 90px would land on the pot total, which
// is the same fault at the other end of the spoke.
const box = felt(300, 216);
const spacing = spacingOn(box, { width: 200, height: 150 });
for (let i = 0; i < 6; i++) {
const seat = seatPixels(i, 6, box);
const chip = chipPixels(i, 6, box, spacing);
const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2);
const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2);
assert.ok(chipReach >= seatReach * 0.38 - 0.5,
`chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`);
}
});
test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => {
const box = felt(400, 300);
const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box));
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos));
});
});
describe("deciding whether a ring of chips fits at all", () => {
// The shape the layout works in: one entry per chip on the felt, carrying its own
// measured size, because a chip is as wide as the number printed on it.
const tableOf = (count, chip = CHIP) => {
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
return Array.from({ length: count }, (_, i) =>
({ index: i, width: chip.width, height: chip.height }));
};
test("a desktop felt has room for one", () => {
const box = felt(700, 500);
const all = tableOf(9);
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true);
});
test("a phone on its side has not, whatever the window's width says about it", () => {
// 692x117, measured on an 844px-wide phone in landscape — a viewport that sails
// past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip
// on the seat across the table from it.
const box = felt(692, 117);
const all = tableOf(9);
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false,
"this is the felt where every chip was painted behind a puck");
});
test("its answer is the one the geometry gives", () => {
// Not a restatement of the two cases above: whatever it returns, it has to agree
// with laying the chips out and measuring them against every puck on the felt.
const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)];
shapes.forEach(box => {
const all = tableOf(9);
const spacing = spacingOn(box);
const aspect = box.width / box.height;
const clear = all.every(({ index: i }) => {
const chip = chipPixels(i, 9, box, spacing);
return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK));
});
assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear,
`${box.width}x${box.height}`);
});
});
test("each chip is asked about at its own size", () => {
// A chip is as wide as the number on it: at 768x1024, four players' chips were
// 40px and one holding a four-figure bet was 47. Answering the question once, at
// the widest, told the whole felt it had no room for a ring — and dropped all
// five chips onto their own players' stacks to solve a problem four of them
// did not have.
// The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88.
const box = felt(672, 672);
const narrow = { width: 40, height: 20 };
const huge = { width: 420, height: 380 };
const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX };
const aspect = box.width / box.height;
assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true,
"a felt this size has room for a ring of ordinary chips");
assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false,
"and none at all for a ring of chips that size");
// Now one of each. The answer has to come from the sizes in the list, not from
// one size standing in for all of them: with one huge chip the ring is off, and
// taking that chip out of the question puts it back.
const mixed = tableOf(9, narrow);
mixed[0] = { index: 0, ...huge };
assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false);
assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true,
"the eight ordinary ones were never the problem");
});
test("and at the chips against each other", () => {
// The ring is a smaller circle than the seats' one and holds the same number of
// things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve
// players, two $10s on the ring sat on top of each other while every puck on the
// felt was clear — so a check that only looks at the pucks says yes to a layout
// that hides one player's bet behind another's.
// The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84,
// pucks 44x55, a pot badge 64x53, and the seven players who had put chips in.
const box = felt(300, 336);
const radii = { rx: 44, ry: 40.84 };
const puck = { width: 44, height: 55 };
const pot = { x: 150, y: 168, width: 64, height: 53 };
const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX };
const aspect = box.width / box.height;
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
const bettors = [2, 3, 4, 5, 6, 7, 8];
const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 }));
const chips = all.map(entry => ({
...entry,
...chipPixels(entry.index, 12, box,
{ ...spacing, chip: { width: entry.width, height: entry.height } }, radii),
}));
const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii));
assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false,
"this table is chosen so the pucks are not the problem");
assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false,
"nor the pot");
assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j &&
boxesOverlap(chip, other, chip, other))), "and so the chips are");
assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false);
});
test("it looks at every puck, not just the chip's own", () => {
// The rule this replaced walked each chip in until it was clear of its *own*
// seat and stopped there, which says nothing about the eight other pucks on the
// felt. 560x190 is a felt where that rule is satisfied and the ring is still no
// good: every chip clears the puck it belongs to, and one of them is sitting on
// a stranger's.
const box = felt(560, 190);
const all = tableOf(9);
const spacing = spacingOn(box);
const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing));
all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK),
`chip ${i + 1} is under its own puck, so this felt does not make the point`));
assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j &&
boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))),
"expected a chip on somebody else's puck — what the old rule could not see");
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false);
});
});
describe("a chip clinging to its puck", () => {
// The fallback when there is no room for a ring: the chip hangs off the puck on the
// side facing the empty middle of the felt.
//
// The felts the app actually draws, with the puck size each one settles on — read
// off the running site at each viewport rather than guessed here, because the tight
// felts shrink the puck too, and a test that holds the desktop's 96x64 puck against
// a 528x83 felt is describing a table the app never draws.
const REAL = [
["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }],
["iPhone on its side", felt(692, 113), { width: 72, height: 55 }],
["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }],
["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }],
];
// Chips are 21x17 everywhere they are anchored — the amount is the only thing on
// one at that size.
const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } };
// What each chip is told to do, worked out for the whole table at once.
const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => {
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
const indices = [...Array(count).keys()];
const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height }));
const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX };
const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing);
return indices.map(i => {
const seat = Render.seatPosition(i, count, box.width / box.height, radii);
const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height };
const off = offs[i];
return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck };
});
};
test("on its own, a chip asks to sit just clear of its puck", () => {
const box = felt(692, 113);
const puck = { width: 72, height: 55 };
const seat = Render.seatPosition(0, 9, box.width / box.height, RADII);
const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII);
const travel = Math.hypot(off.x, off.y);
const ux = off.x / travel, uy = off.y / travel;
assert.ok(Math.abs(travel -
(Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`);
});
test("and gets it, where nothing else is in the way", () => {
// 692x113 is the roomier of the two landscape felts. Every chip clears its own
// puck and none of them has to give ground.
placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => {
assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip),
`seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`);
});
});
test("it goes off the rim, not down the spoke", () => {
// On a felt 692x113 the two are nowhere near the same direction. A seat on the
// top rail has a spoke pointing diagonally inward — which along a slot this flat
// runs sideways into the next seat — while off the rim is very nearly straight
// down, into the empty middle. This is the seam that put two chips on top of
// each other on a Galaxy S8.
const box = felt(692, 113);
const rows = placeAll(9, box, { width: 72, height: 55 });
const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7);
assert.ok(top, "expected a seat up on the right of the top rail");
assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2,
`chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`);
assert.ok(top.off.y > 0, "and downward, toward the middle of the felt");
});
test("no two chips on a full table land on each other", () => {
// Four sides gave three seams. There are no sides now, and this is what that is
// worth — on every felt the app actually draws, at every table size.
REAL.forEach(([name, box, puck]) => {
[6, 9, 12].forEach(count => {
const rows = placeAll(count, box, puck);
for (let a = 0; a < rows.length; a++) {
for (let b = a + 1; b < rows.length; b++) {
assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip),
`${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`);
}
}
});
});
});
test("a chip only gives ground when it is standing on something", () => {
// The point of pulling chips back one at a time rather than by a rule that
// applies to all of them. Stopping every chip at the felt's midline, or scaling
// them all by one factor, takes travel from the seats at the ends of a long
// rail — measured at 692x113, that parked two chips on their own player's stack
// to solve a collision that did not exist.
//
// So: every chip either got everything it asked for, or putting the last step
// back reintroduces a collision it is part of. The step goes back for all of
// them at once, because a facing pair gives ground together and the pass that
// finally separates such a pair moves both ends of it.
const step = Render.CHIP_PULLBACK_STEP_PX;
REAL.forEach(([name, box, puck]) => {
[6, 9, 12].forEach(count => {
const rows = placeAll(count, box, puck).map(row => {
const travel = Math.hypot(row.off.x, row.off.y);
const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel };
const asked = travel < 1e-6 ? 0 :
Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) +
Render.BET_CLEARANCE_PX;
const back = Math.min(asked, travel + step);
return { ...row, travel, asked,
undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } };
});
rows.forEach((row, i) => {
if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return;
const onSomething = rows.some((other, j) => j !== i && (
boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) ||
boxesOverlap(row.undone, other.seat, TIGHT.chip, puck)));
assert.ok(onSomething,
`${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`);
});
});
});
});
test("nobody is pushed further out than they asked to go", () => {
// The pull-back only ever takes travel away. A chip pushed past its clearance
// would be floating out on the felt with nothing to say whose it is.
REAL.forEach(([name, box, puck]) => {
placeAll(12, box, puck).forEach((row, i) => {
const travel = Math.hypot(row.off.x, row.off.y);
if (travel < 1e-6) return;
const ux = row.off.x / travel, uy = row.off.y / travel;
const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) +
Render.BET_CLEARANCE_PX;
assert.ok(travel <= asked + 0.5,
`${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`);
});
});
});
test("the direction turns smoothly all the way round the ring", () => {
// The property a set of discrete sides cannot have, and the reason there are
// none: neighbouring seats get neighbouring directions, with no step anywhere.
const box = felt(560, 400);
const angles = Array.from({ length: 24 }, (_, i) => {
const seat = Render.seatPosition(i, 24, box.width / box.height, RADII);
const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII);
return Math.atan2(off.y, off.x);
});
angles.forEach((angle, i) => {
const next = angles[(i + 1) % angles.length];
let turn = Math.abs(next - angle);
if (turn > Math.PI) turn = 2 * Math.PI - turn;
assert.ok(turn < Math.PI / 4,
`between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`);
});
});
test("it stops short of the pot total as well", () => {
// The other thing in the middle of the felt, and the one number on the table
// that belongs to everybody. A seat on the middle of a rail sends its chip
// straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on
// "Pot $65". The badge is measured rather than assumed to be at the centre —
// it lives on the table, not on either overlay, and the two are not concentric.
const box = felt(528, 83);
const pot = { x: 264, y: 40, width: 96, height: 34 };
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
const indices = [...Array(12).keys()];
const entries = indices.map(i =>
({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height }));
const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot };
const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing);
indices.forEach(i => {
const seat = Render.seatPosition(i, 12, box.width / box.height, RADII);
const chip = { x: (seat.left / 100) * box.width + offs[i].x,
y: (seat.top / 100) * box.height + offs[i].y };
assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot),
`chip ${i + 1} is on the pot total`);
});
});
test("a seat with no rim to be off still gets an answer", () => {
const box = felt(400, 300);
const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII);
assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off));
assert.ok(off.y < 0, "upward, which is where a puck's own writing is not");
});
});
+118
View File
@@ -0,0 +1,118 @@
// The community cards, as a thing the table remembers.
//
// The board is the one part of a hand this tool never had a place for: hole cards go on
// a seat, chips go in the middle, and the five cards everybody plays lived only on the
// felt in front of the dealer. It is opt-in, because most nights nobody wants to enter
// cards at all — and everything that reads it has to behave exactly as it always did
// when it is off.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable } = require("./load");
const dealt = (over = {}) => {
const state = { ...makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2 }), ...over };
Betting.beginHand(state);
return state;
};
describe("a new table", () => {
test("has five empty slots and is not tracking them", () => {
const state = HoldemState.createDefault();
assert.deepEqual(state.board, ["", "", "", "", ""]);
assert.equal(state.trackBoard, false,
"off by default: a dealer who never asked for this should not be asked for cards");
});
});
describe("how many cards are out", () => {
test("none, three, four, five", () => {
assert.equal(Betting.boardCardsFor("preflop"), 0);
assert.equal(Betting.boardCardsFor("flop"), 3);
assert.equal(Betting.boardCardsFor("turn"), 4);
assert.equal(Betting.boardCardsFor("river"), 5);
});
test("and nothing at all for a street that isn't one", () => {
assert.equal(Betting.boardCardsFor("showdown"), 0);
assert.equal(Betting.boardCardsFor(undefined), 0);
});
test("it matches the streets the betting engine actually deals", () => {
// If a street is ever added, this is the table that has to hear about it —
// a street with no entry silently draws no cards.
Betting.STREETS.forEach(street => {
assert.ok(Object.prototype.hasOwnProperty.call(Betting.BOARD_CARDS, street),
`${street} has no card count`);
});
});
});
describe("clearing it", () => {
test("the next hand takes the board with it", () => {
const state = dealt();
state.board = ["AH", "KD", "9C", "4S", "2H"];
HoldemState.startNextHand(state);
assert.deepEqual(state.board, ["", "", "", "", ""]);
});
test("so does a misdeal", () => {
const state = dealt();
state.board = ["AH", "KD", "9C", "", ""];
Betting.takeHandBack(state);
assert.deepEqual(state.board, ["", "", "", "", ""]);
});
test("and so does dealing a hand, however the table got here", () => {
// Dealing twice off the same button — after a misdeal, or a table set up and
// then dealt again — would otherwise rank the last hand's board against this
// hand's cards, and it would look exactly like a real answer.
const state = dealt();
state.board = ["AH", "KD", "9C", "4S", "2H"];
Betting.beginHand(state);
assert.deepEqual(state.board, ["", "", "", "", ""]);
});
test("but turning the tracking off does not", () => {
// A dealer who taps the toggle mid-hand can tap it back; wiping the flop on
// the way past would cost them cards they had already entered.
const state = dealt({ trackBoard: true });
state.board = ["AH", "KD", "9C", "", ""];
state.trackBoard = false;
assert.deepEqual(state.board, ["AH", "KD", "9C", "", ""]);
});
});
describe("a board off a file", () => {
const migrated = board => HoldemState.migrate({
seats: [HoldemState.makePlayer()], board,
}).board;
test("a table saved before boards existed gets an empty one, not tracked", () => {
const parsed = HoldemState.migrate({ seats: [HoldemState.makePlayer()] });
assert.deepEqual(parsed.board, ["", "", "", "", ""]);
assert.equal(parsed.trackBoard, false);
});
test("it is always five slots, however many arrived", () => {
assert.equal(migrated(["AH"]).length, 5);
assert.equal(migrated(["AH", "KD", "9C", "4S", "2H", "3D", "5S"]).length, 5);
assert.equal(migrated("not an array").length, 5);
assert.equal(migrated(null).length, 5);
});
test("anything that isn't a card becomes an empty slot", () => {
assert.deepEqual(migrated(["AH", "ZZ", 7, null, "10H"]), ["AH", "", "", "", ""]);
});
test("and a real one keeps its place, upper-cased", () => {
assert.deepEqual(migrated(["ah", " kd ", "9c", "", ""]), ["AH", "KD", "9C", "", ""]);
});
test("tracking is only on if the file said so, in so many words", () => {
const on = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: true });
const truthy = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: "yes" });
assert.equal(on.trackBoard, true);
assert.equal(truthy.trackBoard, false, "a string is a file that has been edited, not a yes");
});
});
+148
View File
@@ -0,0 +1,148 @@
// Dealing around a player who busted and hasn't bought back in.
//
// A home game produces one of these about once an hour. The table used to deal them
// in anyway, promise a blind from a seat with nothing to post it with, take it from
// nobody, and then sit on a hand that could never finish.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const postedBy = state =>
state.seats
.map((player, index) => (player.committed > 0 ? { seat: index + 1, amount: player.committed } : null))
.filter(Boolean);
test("a seat with no chips and nothing in the pot is broke", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
assert.deepEqual(Betting.brokeSeats(state), [2]);
});
test("an all-in player is not broke — they have money riding on the hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 12;
Betting.beginHand(state);
Betting.allIn(state, 2);
assert.equal(Betting.stackOf(state, state.seats[2]), 0);
assert.equal(Betting.isAllIn(state, state.seats[2]), true);
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("a seat with no stack recorded falls back to the table's and isn't broke", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.defaultStack = 200;
state.seats[2].stack = null;
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("a chair nobody is sitting in isn't broke, it's empty", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("dealing sits the broke seat out and leaves everything else about it alone", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[2].stack = 0;
state.seats[2].name = "Cal";
state.seats[2].buyIns = 100;
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, false);
assert.equal(state.seats[2].folded, false, "sat out, not folded — they didn't muck anything");
assert.equal(state.seats[2].name, "Cal");
assert.equal(state.seats[2].buyIns, 100);
});
test("the blinds skip the broke seat and land on players who can post", () => {
// Button on seat 1, so the blinds would be seats 2 and 3. Seat 2 is busted.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 0;
Betting.beginHand(state);
assert.deepEqual(postedBy(state), [
{ seat: 3, amount: state.smallBlind },
{ seat: 4, amount: state.bigBlind },
]);
});
test("the preview promises what the deal produces", () => {
// The fault this whole step exists to prevent: the preview once read a table the
// deal was about to change underneath it.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 0;
state.bettingOpen = false;
const preview = Betting.blindPreview(state);
assert.deepEqual(preview.satOut, [1]);
Betting.beginHand(state);
const posted = postedBy(state);
assert.equal(posted.find(p => p.amount === state.smallBlind).seat, preview.sbIndex + 1);
assert.equal(posted.find(p => p.amount === state.bigBlind).seat, preview.bbIndex + 1);
});
test("asking for the preview doesn't sit anybody out on its own", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
Betting.blindPreview(state);
assert.equal(state.seats[1].inHand, true, "the preview is a view, not a change");
});
test("with only one player left holding chips there is nothing to preview", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
state.seats[2].stack = 0;
assert.equal(Betting.blindPreview(state), null);
});
test("no chips move when the broke seats are dealt around", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[2].stack = 0;
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before);
});
test("the board isn't running out before the hand is dealt", () => {
// Two busted players and one with chips looks exactly like a table of all-ins to
// the run-out test, and the bar was announcing one over an empty felt.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
state.seats[2].stack = 0;
state.bettingOpen = false;
assert.equal(Betting.isRunningOut(state), false);
});
test("buying back in puts them in the next hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, false);
// What the dealer does when they hand over cash: chips, and back in.
state.seats[2].stack = 100;
state.seats[2].inHand = true;
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, true);
assert.equal(Betting.liveSeats(state).length, 3);
});
test("the table dealt around them counts the right number of players", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 0;
assert.equal(Betting.liveSeats(state).length, 4);
assert.equal(Betting.liveSeats(Betting.tableAsDealt(state)).length, 3);
assert.equal(state.seats[3].inHand, true, "the view didn't touch the table");
});
+76
View File
@@ -0,0 +1,76 @@
// Whether handing a seat chips also puts them back in the game.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, makeTable } = require("./load");
test("a busted player who has been sat out comes back in", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
Betting.beginHand(state); // the deal sits them out
assert.equal(state.seats[1].inHand, false);
assert.equal(Betting.buyInDealsThemIn(state.seats[1]), true);
});
test("a chair being bought into for the first time comes in", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[4].name = "Eve";
state.seats[4].stack = null;
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
});
test("a seat sat out while holding chips stays out", () => {
// A dealer sat them out on purpose and is topping them up. Dealing them back in
// would be the tool guessing at something it wasn't told.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].inHand = false;
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
});
test("somebody already in the hand is left exactly where they are", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
assert.equal(Betting.buyInDealsThemIn(state.seats[0]), false);
// Including a player who is in but out of chips — they're all-in, not sat out.
state.seats[2].stack = 0;
assert.equal(state.seats[2].inHand, true);
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
});
test("a blank stack field reads as nothing recorded, not as a number", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[3].name = "Dee";
state.seats[3].stack = "";
assert.equal(Betting.buyInDealsThemIn(state.seats[3]), true);
});
test("it doesn't ask the table what a missing stack would have been", () => {
// stackOf substitutes the table's starting stack for a seat that has none, which
// would make a seat with nothing recorded look like it was holding chips.
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.defaultStack = 500;
state.seats[4].name = "Eve";
state.seats[4].stack = null;
assert.equal(Betting.stackOf(state, state.seats[4]), 500);
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
});
test("asking doesn't change anything", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
state.seats[2].inHand = false;
const before = JSON.stringify(state);
Betting.buyInDealsThemIn(state.seats[2]);
assert.equal(JSON.stringify(state), before);
});
test("nothing to decide about a seat that isn't there", () => {
assert.equal(Betting.buyInDealsThemIn(null), false);
assert.equal(Betting.buyInDealsThemIn(undefined), false);
});
+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);
});
});
+82
View File
@@ -0,0 +1,82 @@
// The chips to actually push, for an amount.
const test = require("node:test");
const assert = require("node:assert");
const { Money } = require("./load");
// A standard house set.
const LEGEND = [
{ value: 1, color: "#fff" },
{ value: 5, color: "#c0392b" },
{ value: 25, color: "#27ae60" },
{ value: 100, color: "#2c3e50" },
{ value: 500, color: "#8e44ad" },
];
const say = (amount, legend = LEGEND) => Money.formatChipBreakdown(amount, legend);
test("a payout comes back as chips, largest first", () => {
assert.equal(say(132), "1×$100, 1×$25, 1×$5, 2×$1");
});
test("it uses the fewest chips on a normal set", () => {
const { chips } = Money.chipBreakdown(500, LEGEND);
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["1×500"]);
});
test("a denomination that doesn't fit is skipped rather than listed as none", () => {
assert.equal(say(7), "1×$5, 2×$1");
});
test("nothing to pay is nothing to say", () => {
assert.equal(say(0), "");
assert.equal(say(-5), "");
});
test("no legend means no advice, not a wrong answer", () => {
// A table that never set chip colours up gets the amount on its own.
assert.equal(say(132, []), "");
assert.equal(say(132, null), "");
});
test("a legend with no values in it yet is the same as none", () => {
assert.equal(say(132, [{ value: "", color: "#fff" }, { value: 0, color: "#000" }]), "");
});
test("what the smallest chip can't make is reported, not rounded away", () => {
// 50c in a game whose smallest chip is a dollar is the dealer's problem to solve,
// and they can only solve it if they're told.
const { chips, remainder } = Money.chipBreakdown(10.5, LEGEND);
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["2×5"]);
assert.equal(remainder, 0.5);
assert.equal(say(10.5), "2×$5, $0.50 no chip covers");
});
test("cent chips make cent amounts exactly", () => {
const cents = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
assert.equal(say(0.41, cents), "1×$0.25, 3×$0.05, 1×$0.01");
assert.equal(Money.chipBreakdown(0.41, cents).remainder, 0);
});
test("the chips add up to the amount", () => {
for (const amount of [1, 7, 33, 132, 499, 500, 1234, 9999]) {
const { chips, remainder } = Money.chipBreakdown(amount, LEGEND);
const total = chips.reduce((sum, c) => sum + c.count * c.value, 0) + remainder;
assert.equal(Money.round(total), amount, `${amount} should be accounted for`);
}
});
test("a legend in any order still pays largest first", () => {
const jumbled = [{ value: 25 }, { value: 1 }, { value: 500 }, { value: 5 }, { value: 100 }];
assert.equal(say(631, jumbled), "1×$500, 1×$100, 1×$25, 1×$5, 1×$1");
});
test("the colour comes along, so the felt can say it in chips", () => {
const { chips } = Money.chipBreakdown(25, LEGEND);
assert.equal(chips[0].color, "#27ae60");
});
test("a legend that exists but can't reach the amount does say so", () => {
// Different from having no legend at all: this table has told us its chips, and
// the answer is that they don't go that low.
assert.equal(say(3, [{ value: 5 }, { value: 25 }]), "$3 no chip covers");
});
+183
View File
@@ -0,0 +1,183 @@
// Taking a chip colour out of the legend.
//
// This was one click of an unlabelled 24px button, and it was not a change to a legend.
// computeContribution() prices a seat by running the legend over that seat's chip
// counts, so a colour that leaves takes every chip counted in it off the table.
// Measured in a browser on a three-handed table: deleting the $500 row took $22,500 of
// $26,874 with it, with no confirmation, nothing said afterwards, and no way back —
// a colour added again is a new colour with a new id, while the counts stay keyed to
// the one that left. The counts were left behind too, so the saved file carried money
// that nothing could price and nothing could show.
//
// What is tested here is mostly the number in the warning, because a warning that
// overstates is worse than none: it is the one thing a dealer would have to trust at
// the exact moment they are about to lose track of the box.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Money } = require("./load");
const tableWith = ({ counts = [], overrides = [], basePot = null } = {}) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
state.basePotAmount = basePot;
["Christopher", "Mike from work", "Alexandra"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = 500;
state.seats[i].chipCounts = {};
});
counts.forEach(([seat, chipIndex, n]) => {
state.seats[seat].chipCounts[state.chipLegend[chipIndex].id] = n;
});
overrides.forEach(([seat, amount]) => { state.seats[seat].amountOverride = amount; });
return state;
};
const onTable = state => state.seats.reduce(
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
// ---------------------------------------------------------------------------
describe("what a chip colour is holding up", () => {
test("is the money, named seat by seat", () => {
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10]] }); // the $25 chip
const usage = HoldemState.chipColorUsage(state, state.chipLegend[2].id);
assert.equal(usage.total, 350);
assert.equal(usage.chips, 14);
assert.deepEqual(usage.seats.map(s => [s.name, s.count, s.amount]), [
["Christopher", 4, 100],
["Mike from work", 10, 250],
]);
});
test("is nothing at all for a colour nobody counted", () => {
const state = tableWith({ counts: [[0, 2, 4]] });
const usage = HoldemState.chipColorUsage(state, state.chipLegend[4].id);
assert.equal(usage.total, 0);
assert.deepEqual(usage.seats, []);
assert.ok(usage.chip, "the colour itself is still found, so it can be named");
});
test("and nothing for a colour that isn't there", () => {
const state = tableWith();
assert.deepEqual(HoldemState.chipColorUsage(state, "no-such-chip"),
{ chip: null, total: 0, chips: 0, seats: [] });
});
});
describe("the number in the warning is the number the table loses", () => {
// The whole point. Multiplying the chips by their value would have been the obvious
// way to work this out and it is wrong in both directions — the two tests below are
// the two ways it is wrong.
const dropFromRemoving = (state, chipId) => {
const before = onTable(state);
HoldemState.removeChipColor(state, chipId);
return Money.round(before - onTable(state));
};
test("a seat with a typed amount ignores its chips, so its colour is worth nothing", () => {
// computeContribution() reads amountOverride first and never reaches the counts.
// Christopher is holding ten $25 chips on paper and priced at a flat $90.
const state = tableWith({ counts: [[0, 2, 10]], overrides: [[0, 90]] });
const chipId = state.chipLegend[2].id;
const usage = HoldemState.chipColorUsage(state, chipId);
assert.equal(usage.total, 0, "$250 of chips, but the seat is not priced from them");
assert.equal(dropFromRemoving(state, chipId), 0);
});
test("a seat whose chips all leave falls back to the base pot, not to zero", () => {
// The $25s are everything Christopher has counted, so taking them away drops him
// to the table's standing ante — a loss of 50, not of 250.
const state = tableWith({ counts: [[0, 2, 10]], basePot: 200 });
const chipId = state.chipLegend[2].id;
const usage = HoldemState.chipColorUsage(state, chipId);
assert.equal(usage.total, 50, "250 of chips, but only 50 of it actually leaves");
assert.equal(dropFromRemoving(state, chipId), 50);
});
test("and across a spread of tables the two always agree", () => {
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const random = rng(20260906);
let withMoney = 0;
for (let table = 0; table < 2000; table++) {
const counts = [];
for (let seat = 0; seat < 3; seat++) {
for (let chip = 0; chip < 5; chip++) {
if (random() < 0.5) counts.push([seat, chip, 1 + Math.floor(random() * 20)]);
}
}
const overrides = [];
for (let seat = 0; seat < 3; seat++) {
if (random() < 0.25) overrides.push([seat, Math.floor(random() * 400)]);
}
const basePot = random() < 0.5 ? 200 : null;
const chipIndex = Math.floor(random() * 5);
const stated = tableWith({ counts, overrides, basePot });
const chipId = stated.chipLegend[chipIndex].id;
const claimed = HoldemState.chipColorUsage(stated, chipId).total;
const actual = dropFromRemoving(tableWith({ counts, overrides, basePot }), chipId);
if (claimed !== 0) withMoney++;
assert.equal(claimed, actual,
`table ${table}: warned ${claimed}, table moved ${actual}\n` +
JSON.stringify({ counts, overrides, basePot, chipIndex }));
}
// Guards the generator: shapes that never put money on a colour would make every
// assertion above compare zero with zero.
assert.ok(withMoney > 500, `only ${withMoney} of 2000 tables had money on the colour`);
});
test("and it can be a gain, which is said as one", () => {
// Fourteen $1 chips against a $200 standing ante: once the colour goes, the seat
// stops being priced from its chips and falls through to the ante, so the table
// goes *up* by 186. Found by the randomised check above, not by hand.
const state = tableWith({ counts: [[1, 0, 14]], basePot: 200 });
const usage = HoldemState.chipColorUsage(state, state.chipLegend[0].id);
assert.equal(usage.total, -186);
assert.equal(dropFromRemoving(state, state.chipLegend[0].id), -186);
});
});
describe("removing it", () => {
test("takes the counts with it, so nothing is left priced by a colour that is gone", () => {
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10], [1, 0, 3]] });
const chipId = state.chipLegend[2].id;
HoldemState.removeChipColor(state, chipId);
assert.equal(state.chipLegend.filter(c => c.id === chipId).length, 0);
state.seats.forEach(player => assert.ok(!(chipId in (player.chipCounts || {})),
"a count against a colour no legend entry answers to is money nothing can show"));
// The other colour on that seat is untouched.
assert.equal(state.seats[1].chipCounts[state.chipLegend[0].id], 3);
});
test("reports what it cost, measured before anything moved", () => {
const state = tableWith({ counts: [[0, 2, 4]] });
const usage = HoldemState.removeChipColor(state, state.chipLegend[2].id);
assert.equal(usage.total, 100);
assert.equal(usage.seats.length, 1);
});
test("and a colour that isn't there changes nothing", () => {
const state = tableWith({ counts: [[0, 2, 4]] });
const before = onTable(state);
const usage = HoldemState.removeChipColor(state, "no-such-chip");
assert.equal(usage.chip, null);
assert.equal(state.chipLegend.length, 5);
assert.equal(onTable(state), before);
});
});
+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");
});
});
+164
View File
@@ -0,0 +1,164 @@
// The stylesheet has to keep picking from the scales.
//
// Before the token block went in, holdem.css used 42 different spacing values, 31 font
// sizes, 14 border-radii and 139 colours, and defined three custom properties. None of
// that was anybody's decision — each number was chosen at the moment it was needed, and
// the result read as crowded even where nothing overlapped, because a 0.42rem gap next
// to a 0.45rem gap next to a 0.5rem gap gives the eye no rhythm to lock onto.
//
// A one-off cleanup would last until the next `padding: 0.35rem`. So the scales are
// checked here instead: a new value has to be a step on one of them, or an exception
// with a reason written next to it.
//
// This reads the stylesheet as text rather than through a CSS parser, on purpose — the
// rest of this suite has no dependencies and runs in a second, and a regex over
// declarations is enough to catch the thing that actually happens, which is somebody
// (me) typing a number instead of reaching for a token.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS_PATH = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css");
const css = fs.readFileSync(CSS_PATH, "utf8");
// Comments carry measurements and rationale — "measured at 342px tall on a phone" —
// and none of that is a declaration. Strip them before looking for values.
const declarations = css.replace(/\/\*[\s\S]*?\*\//g, "");
// Everything after the token block. The :root block is where the raw values are
// supposed to be.
const body = declarations.slice(declarations.indexOf("#potBadgeAmount,"));
const valuesIn = (properties, source = body) => {
const found = [];
// The lookbehind rejects a hyphen as well as a word character, so `scroll-padding`
// isn't read as `padding`. It caught the pot wizard's scroll-padding the moment
// that went in — correctly by its own rules, wrongly in substance: those two values
// are the measured heights of the dialog's sticky header and footer, a clearance
// like the action bar's, not a step in the spacing rhythm.
const re = new RegExp(`(?<![\\w-])(${properties.join("|")})\\s*:([^;}]*)`, "g");
let m;
while ((m = re.exec(source)) !== null) {
const [, prop, value] = m;
// calc() and env() do their own arithmetic on tokens; the tokens inside them
// are still checked, because the regex below looks at bare numbers only.
(value.match(/(?<![\w-])[0-9.]+(rem|px|em)\b/g) || []).forEach(v => found.push({ prop, value: v }));
}
return found;
};
describe("the spacing scale", () => {
// Hairlines and structural clearances, each one deliberate:
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat
// puck's name gives the dealer button beside it.
// 5rem/9rem — how far the page has to scroll past the fixed action
// bar. Not rhythm; clearance for a thing of a known size.
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]);
test("every gap, margin and padding comes off the scale", () => {
const offenders = valuesIn(
["padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
"gap", "row-gap", "column-gap"])
.filter(v => !ALLOWED.has(v.value));
assert.deepEqual(offenders, [],
"use var(--sp-0 … --sp-6), or add the value to ALLOWED with a reason");
});
test("and the scale is the one the tokens define", () => {
["--sp-0", "--sp-1", "--sp-2", "--sp-3", "--sp-4", "--sp-5", "--sp-6"]
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
});
});
describe("the type scale", () => {
// 16px — the size below which iOS zooms the page when a field is focused, so
// every text input states it. Not a typographic choice.
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
// a six-figure stack on a 58px seat puck.
const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]);
test("every font-size comes off the scale", () => {
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
assert.deepEqual(offenders, [],
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
});
test("and the scale is the one the tokens define", () => {
["--fs-2xs", "--fs-xs", "--fs-sm", "--fs-base", "--fs-md", "--fs-lg", "--fs-xl", "--fs-2xl"]
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
});
});
describe("the corner scale", () => {
test("every border-radius comes off it", () => {
const offenders = valuesIn(["border-radius"]).filter(v => v.value !== "2px");
assert.deepEqual(offenders, [], "use var(--r-sm | --r-md | --r-lg | --r-pill)");
});
});
describe("the tokens themselves", () => {
test("every var() the stylesheet reaches for exists", () => {
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
// Published from JS at runtime rather than declared here — see actionBar.js for
// the first two, render.js for the seat width and the chip's denomination colour,
// which is picked per chip from the amount on it, and feedback.js for the
// toast's height, which is one line or two depending on what is being said.
// The seat sheet's height is the same idea as the action bar's, and for the same
// reason: two things fixed to the bottom of a phone, and the toast has to clear
// whichever is taller. It changes when a section of the sheet is unfolded, so it
// is measured rather than written down — see Render.watchSeatSheetHeight.
const fromScript = new Set([
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
"--toastHeight", "--seatSheetHeight",
]);
// Defined by the site's own stylesheet, not this one.
const fromSite = new Set(["--font-mono"]);
const missing = [...used].filter(t =>
!fromScript.has(t) && !fromSite.has(t) && !new RegExp(`\\${t}\\s*:`).test(css));
assert.deepEqual(missing, []);
});
test("and the dark surfaces stay off pure black", () => {
// Shadows are invisible on #000, so depth has to come from a lighter surface —
// and white text on true black is the pairing that smears on an OLED phone,
// which is what this tool is used on.
const surfaces = [...css.matchAll(/--surface-\d+:\s*(#[0-9a-fA-F]{3,8})/g)].map(m => m[1].toLowerCase());
assert.ok(surfaces.length >= 4, "the surface ramp should have four steps");
surfaces.forEach(hex => {
assert.notEqual(hex, "#000");
assert.notEqual(hex, "#000000");
});
});
});
describe("numbers that change while the dealer watches", () => {
test("are set in tabular figures", () => {
// A "1" is narrower than an "8" in a proportional face, so a pot ticking from
// $18 to $21 shuffles its digits sideways and the eye reads movement where
// there is only arithmetic.
assert.match(css, /font-variant-numeric:\s*tabular-nums/);
["#potBadgeAmount", ".seatPuckStack", ".actionPot", ".standingsRow"]
.forEach(sel => assert.ok(css.includes(sel),
`${sel} should be in the tabular-figures list`));
});
});
describe("controls that must not resize under a thumb", () => {
test("the undo pill has a fixed width, not a content width", () => {
// It names the last action, so its label changes on every tap. Measured across
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndo rule not found");
assert.match(rule[0], /width:\s*[0-9.]+rem/);
});
test("and its label is the part that ellipsizes", () => {
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndoLabel rule not found");
assert.match(rule[0], /text-overflow:\s*ellipsis/);
});
});
+94
View File
@@ -0,0 +1,94 @@
// Chips the dealer took off the table on purpose.
//
// Starting the next hand with money still in the middle destroys it — the tool asks
// first and says so afterwards. What it didn't do was remember, so the bank check saw
// only that chips were gone and reported them as missing.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState, Money, makeTable } = require("./load");
const table = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
state.seats.slice(0, 4).forEach(player => { player.buyIns = 200; });
return state;
};
test("a table with nothing thrown away balances as before", () => {
const bank = HoldemState.bankReconciliation(table());
assert.equal(bank.balanced, true);
assert.equal(bank.discarded, 0);
});
test("chips that went missing are still reported", () => {
// The check exists for this: cash in the box with no chips to match it.
const state = table();
state.seats[0].stack = 150;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -50);
});
test("an abandoned pot is accounted for rather than counted as missing", () => {
const state = table();
state.seats[1].stack = 199;
state.seats[2].stack = 198;
state.seats[3].stack = 198;
state.discarded = 5;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.chipsInPlay, 795);
assert.equal(bank.discarded, 5);
assert.equal(bank.balanced, true, "the box knows exactly where that $5 went");
});
test("throwing a pot away doesn't hide a real shortfall underneath it", () => {
const state = table();
state.seats[1].stack = 199;
state.seats[2].stack = 198;
state.seats[3].stack = 148; // $50 genuinely unaccounted for as well
state.discarded = 5;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -50);
});
test("a nonsense discarded value can't move the answer", () => {
const state = table();
state.discarded = -100;
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
state.discarded = "not a number";
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
});
test("a table saved before this existed reads as nothing thrown away", () => {
const saved = JSON.stringify(table());
const parsed = JSON.parse(saved);
delete parsed.discarded;
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.discarded, 0);
assert.equal(HoldemState.bankReconciliation(loaded).balanced, true);
delete globalThis.localStorage;
});
test("the amounts still add up the way the panel shows them", () => {
const state = table();
state.seats[1].stack = 199;
state.seats[2].stack = 198;
state.seats[3].stack = 198;
state.discarded = 5;
const bank = HoldemState.bankReconciliation(state);
assert.equal(Money.round(bank.chipsInPlay + bank.discarded), bank.cashIn);
});
+97
View File
@@ -0,0 +1,97 @@
// The things the dealer wrote down, in the file they hand over.
//
// The export already carried the hands, the standings and the settle-up. It didn't
// carry either of the two places a dealer types context — the per-seat fields and the
// table's own notes — so the one place an IOU was written down was the one place it
// couldn't leave.
const test = require("node:test");
const assert = require("node:assert");
const { HandHistoryExport, HoldemState, makeTable } = require("./load");
const tableWithNotes = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].buyIns = 200;
});
state.seats[0].customFields = [
{ label: "Seat rent", value: "paid to 2am" },
{ label: "Venmo", value: "@ann" },
];
state.seats[1].customFields = [{ label: "IOU", value: "$20 to Ann" }];
return state;
};
test("a seat's own fields ride along with its standings row", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
assert.match(csv, /Seat rent: paid to 2am; Venmo: @ann/);
assert.match(csv, /IOU: \$20 to Ann/);
});
test("the money columns keep their order, with notes on the end", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
assert.match(csv, /Player,Paid In,Won,Net,Bought In,Chips Now,Up\/Down,Notes/);
});
test("a seat with nothing written against it gets an empty cell, not the word undefined", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
assert.ok(calRow, "Cal should have a row");
assert.ok(calRow.endsWith(","), `expected an empty last cell, got ${calRow}`);
assert.ok(!/undefined|null/.test(calRow));
});
test("a blank label or value doesn't produce a stray colon", () => {
const state = tableWithNotes();
state.seats[2].customFields = [{ label: "Note", value: "" }, { label: "", value: "" }];
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
assert.match(calRow, /Note/);
assert.ok(!/;\s*;/.test(calRow), "an entirely blank field shouldn't take a slot");
});
test("the table's notes are in the file", () => {
const state = tableWithNotes();
state.notes = "High-hand promo until midnight.";
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: state.notes });
assert.match(csv, /Table Notes/);
assert.match(csv, /High-hand promo until midnight\./);
});
test("a multi-line note survives as one cell", () => {
// csvField quotes anything with a line break in it, so the note doesn't turn into
// rows a spreadsheet reads as more players.
const state = tableWithNotes();
const notes = "Line one\nLine two, with a comma\nLine \"three\"";
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes });
assert.match(csv, /"Line one\nLine two, with a comma\nLine ""three"""/);
});
test("no notes means no section, not an empty heading", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: " " });
assert.ok(!/Table Notes/.test(csv));
});
test("the export still works with no standings at all", () => {
assert.doesNotThrow(() => HandHistoryExport.toCsv([], [], { notes: "just a note" }));
const csv = HandHistoryExport.toCsv([], [], { notes: "just a note" });
assert.match(csv, /Table Notes/);
});
test("asking for the standings doesn't change the seats", () => {
const state = tableWithNotes();
const before = JSON.stringify(state.seats);
HoldemState.sessionStandings(state);
assert.equal(JSON.stringify(state.seats), before);
});
+144
View File
@@ -0,0 +1,144 @@
// 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);
});
});
describe("when the felt changes size, so does everything on it", () => {
// The felt is published as a custom property and the seats follow it on their own,
// because they are placed in percentages of it. A bet chip anchored inside a seat
// is not: it is placed in pixels off that seat's centre, worked out against the felt
// as it was, and nothing re-ran that when the felt moved.
//
// Measured at 640x360 with five players, on a felt that lost 23px of height: the
// chips were positioned for the old one and painted on the new, and seat 3's $5 came
// to rest on seat 4's puck. The seat pucks had the same problem — they are searched
// for a width the felt no longer has — and two pairs of them were overlapping.
//
// A side pot appearing does this too: it is 55px off the felt, mid-hand.
const fakeDom = (windowHeight = 900) => {
const root = { style: { values: {},
setProperty(k, v) { this.values[k] = v; },
getPropertyValue(k) { return this.values[k] || ""; } } };
global.document = {
documentElement: root,
getElementById: id => (id === "pokerTableWrap"
? { getBoundingClientRect: () => ({ top: 146 }) }
: null),
};
global.window = { innerHeight: windowHeight, scrollY: 0 };
return root;
};
const spyRender = () => {
const calls = { density: 0, seats: 0 };
global.Render = {
state: {},
updateSeatDensity() { calls.density++; },
renderSeats() { calls.seats++; },
};
return calls;
};
test.afterEach(() => {
delete global.document;
delete global.window;
delete global.Render;
});
test("a felt that changed height lays the seats out again", () => {
fakeDom();
const calls = spyRender();
ActionBar.publishFeltRoom(117);
assert.equal(calls.seats, 1, "first publish sets a height it did not have");
ActionBar.publishFeltRoom(172);
assert.equal(calls.seats, 2, "and a side pot grew the bar by 55px");
});
test("a felt that did not change is left alone", () => {
fakeDom();
const calls = spyRender();
ActionBar.publishFeltRoom(117);
ActionBar.publishFeltRoom(117);
ActionBar.publishFeltRoom(117);
assert.equal(calls.seats, 1,
"re-laying the table on every resize tick would be work for nothing");
assert.equal(calls.density, 3, "the cheap re-measure still runs each time");
});
test("it publishes the height whether or not anything is listening", () => {
const root = fakeDom();
delete global.Render;
assert.doesNotThrow(() => ActionBar.publishFeltRoom(117));
assert.match(root.style.getPropertyValue("--feltRoomPx"), /^\d+px$/);
});
});
+165
View File
@@ -0,0 +1,165 @@
// What shape the felt ends up, which is a question about which rule wins.
//
// feltRoom.test.js covers how much room there is. This covers what the stylesheet does
// with it, and it exists because of a bug that no amount of reading caught: the upright
// oval for phones was written into the `max-width: 700px` block, and the `max-width:
// 900px` block below it set the same custom property back to 1. Both queries match a
// phone, the two declarations have identical specificity, and the later one wins. The
// rule was correct, the arithmetic was correct, and the property was 1 on every phone
// on the site. It measured as a no-op and would have shipped as one.
//
// So this walks the media queries the way the cascade does — in source order, keeping
// the last one that matches — instead of trusting that a rule written for a viewport is
// the rule that viewport gets.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// Comments carry example numbers and at least one worked-through `--feltAspect: 1`, so
// they come out before anything here counts declarations.
const SOURCE = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
// --- just enough of a media query engine to answer "does this viewport match?" -------
//
// Only the forms this stylesheet actually uses: width and height bounds, orientation,
// and `and` between them. Anything else is refused loudly rather than guessed at, so a
// new kind of query can't quietly start matching everything.
const matches = (condition, view) => {
if (!condition) return true;
return condition.split(/\s+and\s+/).every(part => {
const m = part.trim().match(/^\(\s*([a-z-]+)\s*:\s*([^)]+?)\s*\)$/);
assert.ok(m, `feltShape can't read the query "${part}" — teach it or simplify the CSS`);
const [, feature, raw] = m;
const px = parseFloat(raw);
switch (feature) {
case "min-width": return view.width >= px;
case "max-width": return view.width <= px;
case "min-height": return view.height >= px;
case "max-height": return view.height <= px;
case "orientation":
return raw === (view.width > view.height ? "landscape" : "portrait");
default:
assert.fail(`feltShape doesn't know the media feature "${feature}"`);
}
});
};
// Every style rule in the file, in source order, with the media condition it sits
// under. Written as a brace walk rather than a regex because a regex for `max-height`
// also finds it in `@media (orientation: landscape) and (max-height: 560px)`, and a
// media feature is not a declaration.
const rules = () => {
const out = [];
const open = [];
let prelude = "", i = 0;
while (i < SOURCE.length) {
const c = SOURCE[i];
if (c === "{") {
const head = prelude.trim();
prelude = "";
if (head.startsWith("@")) { open.push(head); i++; continue; }
let depth = 1, j = i + 1;
while (j < SOURCE.length && depth > 0) {
if (SOURCE[j] === "{") depth++;
else if (SOURCE[j] === "}") depth--;
j++;
}
const media = open.filter(h => h.startsWith("@media"))
.map(h => h.replace(/^@media\s*/, "")).join(" and ");
out.push({ selector: head, condition: media || null, body: SOURCE.slice(i + 1, j - 1) });
i = j;
continue;
}
if (c === "}") { open.pop(); prelude = ""; i++; continue; }
prelude += c;
i++;
}
return out;
};
const ALL_RULES = rules();
// The cascade, for equal-specificity declarations of one property on one selector: the
// last one whose query matches is the one the viewport gets.
const winnerAt = (selector, prop, view) => {
const winning = ALL_RULES
.filter(r => r.selector === selector && matches(r.condition, view))
.map(r => r.body.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`)))
.filter(Boolean)
.pop();
assert.ok(winning, `nothing sets ${prop} on ${selector} at ${view.width}x${view.height}`);
return winning[1].trim();
};
const PHONES = [
{ width: 360, height: 640, name: "Galaxy S8" },
{ width: 375, height: 667, name: "iPhone SE" },
{ width: 390, height: 844, name: "iPhone 14" },
{ width: 412, height: 915, name: "Pixel 7" },
{ width: 430, height: 932, name: "Pro Max" },
];
// ---------------------------------------------------------------------------
describe("the shape a phone gets", () => {
test("is an oval standing up, on every one of them", () => {
PHONES.forEach(phone => {
const aspect = parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", phone));
assert.ok(aspect > 1.2,
`${phone.name} lands on --feltAspect: ${aspect}. A portrait phone is ` +
`tall and this is what uses it; anything at or under 1 is the square ` +
`felt that threw the room away.`);
});
});
test("and a wide screen still gets one lying down", () => {
// The desktop felt is the shape it always was. Whatever the phones do, this is
// the one a poker table looks like when there is room for it.
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", { width: 1280, height: 900 })), 0.72);
});
test("with the middle widths in between left round", () => {
// 701-900px is one column and wide enough that neither oval is called for. It
// has to be written as a range: a bare max-width would match the phones too and,
// sitting lower in the file, would win there.
[{ width: 768, height: 1024 }, { width: 900, height: 700 }].forEach(view => {
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", view)), 1);
});
});
});
describe("what stops the oval", () => {
const phoneRule = winnerAt("#pokerTableWrap", "max-height", { width: 390, height: 844 });
test("is the room and nothing but the room", () => {
// The oval is a preference and the room is a fact. Whatever shape is asked for
// above, the felt gives way to the measurement rather than the other way round,
// because the thing on the other side of the cap is a seat behind the action bar.
assert.equal(phoneRule, "var(--feltRoomPx, 100vh)");
});
test("and it is not floored, however tempting that is", () => {
// A floor at the desktop oval — max(var(--feltRoomPx), 72cqw) — was written here
// and measured out again. Twelve players all in with nine side pots is the
// tallest the bar gets, 341px, and at 360x640 that leaves 142px of room. Floored
// to 242 the felt keeps its shape and hides five of the twelve seats behind the
// bar. Unfloored at 142 it is flat and all twelve are visible.
//
// The slit it was defending against wasn't reachable: that came from forcing
// --feltRoomPx to 10px by hand, and the real floor is FELT_FLOOR_PX at 96.
assert.doesNotMatch(phoneRule, /max\(/,
"a floor here trades a flat table for a covered seat, which is the worse of " +
"the two — see the comment above the rule");
});
test("and the width is let go of, so the height can be the one that binds", () => {
// The wide layout sizes the felt from its width and caps that. On a phone the
// width is the column and the height is what's scarce, so the cap moves.
assert.equal(winnerAt("#pokerTableWrap", "max-width", { width: 390, height: 844 }), "none");
});
});
+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");
});
});
+415
View File
@@ -0,0 +1,415 @@
// What beats what.
//
// This is the one module in the tool that can pay the wrong person without anybody
// noticing. A pot wizard that asks "who won?" is only ever as wrong as the dealer
// ticking the box; one that answers the question itself is wrong on its own account,
// silently, and a table that has come to trust it will not check. So the ordering is
// tested from both ends: every category against every other, the traps that a
// hand-rolled evaluator gets wrong (the wheel, the counterfeited kicker, the board
// that plays), and then a lot of random seven-card hands against a second
// implementation written a different way.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HandRank } = require("./load");
const best = (...cards) => HandRank.best(cards.flat());
const beats = (a, b) => HandRank.compare(a, b) > 0;
const chops = (a, b) => HandRank.compare(a, b) === 0;
// A hand written as one string, for the tests that list a lot of them.
const hand = str => str.trim().split(/\s+/);
describe("the five-card hand", () => {
const of = str => HandRank.evaluate5(hand(str));
test("each category is recognised", () => {
const cases = [
["AS KS QS JS TS", HandRank.STRAIGHT_FLUSH, "Royal flush"],
["9H 8H 7H 6H 5H", HandRank.STRAIGHT_FLUSH, "Straight flush, nine high"],
["7C 7D 7H 7S 2C", HandRank.FOUR_OF_A_KIND, "Four of a kind, sevens"],
["KC KD KH 3S 3C", HandRank.FULL_HOUSE, "Full house, kings full of threes"],
["AD JD 8D 5D 2D", HandRank.FLUSH, "Flush, ace high"],
["9C 8D 7H 6S 5C", HandRank.STRAIGHT, "Straight, nine high"],
["QC QD QH 7S 2C", HandRank.THREE_OF_A_KIND, "Three of a kind, queens"],
["AC AD 8H 8S 4C", HandRank.TWO_PAIR, "Two pair, aces and eights"],
["QC QD 9H 5S 2C", HandRank.ONE_PAIR, "Pair of queens"],
["AC JD 8H 5S 2C", HandRank.HIGH_CARD, "Ace high"],
];
cases.forEach(([cards, category, described]) => {
const ranked = of(cards);
assert.equal(ranked.category, category, cards);
assert.equal(HandRank.describe(ranked), described, cards);
});
});
test("the categories are in the right order", () => {
// Listed weakest first, then every pair checked both ways round. The order
// itself is the thing being asserted, so it is written out rather than derived
// from the constants it is meant to be checking.
const ladder = [
"AC JD 8H 5S 2C", // ace high
"2C 2D 8H 5S 3C", // a pair of deuces
"AC AD 8H 8S 4C", // two pair
"3C 3D 3H 7S 2C", // trips
"9C 8D 7H 6S 5C", // straight
"AD JD 8D 5D 2D", // flush
"2C 2D 2H 3S 3C", // full house
"7C 7D 7H 7S 2C", // quads
"9H 8H 7H 6H 5H", // straight flush
];
for (let low = 0; low < ladder.length; low++) {
for (let high = low + 1; high < ladder.length; high++) {
assert.ok(beats(of(ladder[high]), of(ladder[low])),
`${ladder[high]} should beat ${ladder[low]}`);
assert.ok(!beats(of(ladder[low]), of(ladder[high])),
`${ladder[low]} should not beat ${ladder[high]}`);
}
}
});
test("the same hand in different suits is a chop", () => {
assert.ok(chops(of("AC KD 8H 5S 2C"), of("AS KH 8D 5C 2H")));
assert.ok(chops(of("QC QD 9H 5S 2C"), of("QH QS 9D 5C 2H")));
});
test("nothing but five real cards is a hand", () => {
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S")), null, "four");
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S 2C 3D")), null, "six");
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", "ZZ"]), null, "nonsense");
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", ""]), null, "an empty slot");
assert.equal(HandRank.evaluate5(null), null);
});
test("case does not matter, because the picker is not the only way cards get in", () => {
assert.ok(chops(of("ac kd 8h 5s 2c"), of("AC KD 8H 5S 2C")));
});
});
describe("the wheel", () => {
const of = str => HandRank.evaluate5(hand(str));
// The straight that runs through the ace at the bottom. An evaluator that scores a
// straight by its top card and lets the ace keep its usual value makes A-2-3-4-5
// the best straight there is instead of the worst — which pays the wrong player
// every time it comes up against a broadway.
test("A-2-3-4-5 is a straight", () => {
assert.equal(of("AC 2D 3H 4S 5C").category, HandRank.STRAIGHT);
assert.equal(HandRank.describe(of("AC 2D 3H 4S 5C")), "Straight, five high");
});
test("and it is the lowest one", () => {
assert.ok(beats(of("6C 5D 4H 3S 2C"), of("AC 2D 3H 4S 5C")),
"six high beats the wheel");
assert.ok(beats(of("AC KD QH JS TC"), of("AC 2D 3H 4S 5C")),
"broadway beats the wheel");
});
test("the same goes for a steel wheel", () => {
const steel = of("AH 2H 3H 4H 5H");
assert.equal(steel.category, HandRank.STRAIGHT_FLUSH);
assert.equal(HandRank.describe(steel), "Straight flush, five high");
assert.ok(beats(of("6H 5H 4H 3H 2H"), steel));
assert.ok(beats(of("AS KS QS JS TS"), steel));
});
test("an ace with a four-card run either side of it is not a straight", () => {
// K-A-2-3-4 does not wrap.
assert.equal(of("KC AD 2H 3S 4C").category, HandRank.HIGH_CARD);
assert.equal(of("QC KD AH 2S 3C").category, HandRank.HIGH_CARD);
});
});
describe("kickers", () => {
const of = str => HandRank.evaluate5(hand(str));
test("the fifth card settles two pair", () => {
assert.ok(beats(of("AC AD 8H 8S KC"), of("AH AS 8D 8C QC")));
assert.ok(chops(of("AC AD 8H 8S KC"), of("AH AS 8D 8C KD")));
});
test("and a pair, three kickers deep", () => {
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S KD 7C 2D")), "first kicker");
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S AD 6C 2D")), "second");
assert.ok(beats(of("9C 9D AH 7S 3C"), of("9H 9S AD 7C 2D")), "third");
});
test("the higher pair beats the better kicker", () => {
assert.ok(beats(of("TC TD 3H 2S 4C"), of("9C 9D AH KS QC")));
});
test("two pair is read by its higher pair first", () => {
assert.ok(beats(of("KC KD 2H 2S 3C"), of("QC QD JH JS AC")));
});
test("a full house is read by its trips first", () => {
assert.ok(beats(of("3C 3D 3H 2S 2C"), of("2D 2H 2C AS AD")));
});
test("a flush is read all the way down", () => {
assert.ok(beats(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 2H")));
assert.ok(chops(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 3H")));
});
});
describe("the best five out of seven", () => {
test("it finds the flush the two hole cards do not both belong to", () => {
const ranked = best(["AH", "2C"], ["KH", "QH", "7H", "3H", "9S"]);
assert.equal(ranked.category, HandRank.FLUSH);
assert.equal(HandRank.describe(ranked), "Flush, ace high");
});
test("it plays the board when the board is better than anything you hold", () => {
// Nobody can beat a royal on the board; both hands are the board itself.
const board = ["AS", "KS", "QS", "JS", "TS"];
const one = best(["2C", "3D"], board);
const two = best(["7H", "8H"], board);
assert.equal(HandRank.describe(one), "Royal flush");
assert.ok(chops(one, two), "the board plays, so it is a chop");
});
test("it does not use three hole cards, because there are only two", () => {
const ranked = best(["AC", "AD"], ["AH", "KS", "KD", "2C", "3H"]);
assert.equal(HandRank.describe(ranked), "Full house, aces full of kings");
});
test("a counterfeited two pair falls back on the board", () => {
// Holding 6-5 on a board of 8-8-K-K-A: the player's two pair is counterfeited —
// the best five are the board's two pair with the ace, and the sixes never play.
const ranked = best(["6C", "5D"], ["8H", "8S", "KC", "KD", "AC"]);
assert.equal(HandRank.describe(ranked), "Two pair, kings and eights");
assert.deepEqual([...ranked.cards].sort(), ["8H", "8S", "AC", "KC", "KD"].sort());
});
test("five, six and seven cards are all rankable; four is not", () => {
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC"]), "five");
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D"]), "six");
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D", "3H"]), "seven");
assert.equal(best(["AC", "KD"], ["QH", "JS"]), null, "four");
});
test("the same card twice is not a hand", () => {
// A mis-entry, not a monster. Ranking it would mean ranking a deck that does
// not exist, and the answer would look exactly as confident as a real one.
assert.equal(best(["AC", "AC"], ["KH", "QS", "JD", "2C", "3H"]), null);
assert.equal(best(["AC", "KD"], ["KD", "QS", "JD", "2C", "3H"]), null);
});
});
describe("the showdown", () => {
const board = ["AH", "KD", "9C", "4S", "2H"];
test("it names one winner and says how it read every hand", () => {
const result = HandRank.showdown(board, [
{ id: "a", cards: ["AS", "KC"] }, // two pair, aces and kings
{ id: "b", cards: ["9H", "9S"] }, // trips
{ id: "c", cards: ["AC", "3D"] }, // a pair of aces
]);
assert.equal(result.complete, true);
assert.deepEqual(result.winners, ["b"]);
assert.deepEqual(result.players.map(p => p.place), [2, 1, 3]);
assert.deepEqual(result.players.map(p => HandRank.describe(p.hand)), [
"Two pair, aces and kings",
"Three of a kind, nines",
"Pair of aces",
]);
});
test("a chop comes back as two winners on the same place", () => {
const result = HandRank.showdown(board, [
{ id: "a", cards: ["AS", "QC"] },
{ id: "b", cards: ["AD", "QH"] },
{ id: "c", cards: ["7H", "5S"] },
]);
assert.deepEqual(result.winners.sort(), ["a", "b"]);
assert.deepEqual(result.players.map(p => p.place), [1, 1, 3],
"and the player behind them is third, not second");
});
test("an incomplete board is not a showdown", () => {
const result = HandRank.showdown(["AH", "KD", "9C"], [
{ id: "a", cards: ["AS", "KC"] },
{ id: "b", cards: ["9H", "9S"] },
]);
assert.equal(result.boardComplete, false);
assert.equal(result.complete, false);
assert.deepEqual(result.winners, [], "it must not name one");
});
test("one unknown hand and it will not say who won", () => {
// Who is *ahead* and who *won* are different claims, and the second one is the
// only one that can pay somebody. A player who never turned their cards over
// could have anything.
const result = HandRank.showdown(board, [
{ id: "a", cards: ["AS", "KC"] },
{ id: "b", cards: ["", ""] },
]);
assert.equal(result.complete, false);
assert.deepEqual(result.winners, []);
assert.deepEqual(result.unknown, ["b"]);
assert.ok(result.players[0].hand, "but it still reads the hands it can");
assert.equal(result.players[1].hand, null);
});
test("a card in two places stops it dead", () => {
const result = HandRank.showdown(board, [
{ id: "a", cards: ["AH", "KC"] }, // the ace of hearts is on the board
{ id: "b", cards: ["9H", "9S"] },
]);
assert.equal(result.duplicated, true);
assert.equal(result.complete, false);
assert.deepEqual(result.winners, []);
});
test("no players at all is not a showdown either", () => {
const result = HandRank.showdown(board, []);
assert.equal(result.complete, false);
assert.deepEqual(result.winners, []);
});
});
describe("against a second opinion", () => {
// The strongest check available without a poker library: a completely separate
// ranking, written the other way round — every hand turned into a sortable string
// by counting ranks, with no shared helper between it and the module. Where the two
// disagree, one of them is wrong and the test says which hand it was.
const VALUE = { T: 10, J: 11, Q: 12, K: 13, A: 14 };
const val = card => VALUE[card[0]] || Number(card[0]);
function reference5(cards) {
const ranks = cards.map(val).sort((x, y) => y - x);
const suited = new Set(cards.map(c => c[1])).size === 1;
const tally = {};
ranks.forEach(r => { tally[r] = (tally[r] || 0) + 1; });
const bySize = Object.keys(tally)
.map(Number)
.sort((x, y) => tally[y] - tally[x] || y - x);
const sizes = bySize.map(r => tally[r]).join("");
const unique = [...new Set(ranks)];
let run = null;
if (unique.length === 5) {
if (unique[0] - unique[4] === 4) run = unique[0];
else if (unique.join(",") === "14,5,4,3,2") run = 5;
}
// [category, ...tiebreak], the same shape, arrived at from the group sizes
// rather than from a chain of ifs about shape[0] and shape[1].
if (suited && run !== null) return [8, run];
if (sizes === "41") return [7, ...bySize];
if (sizes === "32") return [6, ...bySize];
if (suited) return [5, ...ranks];
if (run !== null) return [4, run];
if (sizes === "311") return [3, ...bySize];
if (sizes === "221") return [2, ...bySize];
if (sizes === "2111") return [1, ...bySize];
return [0, ...ranks];
}
const worse = (a, b) => {
for (let i = 0; i < Math.max(a.length, b.length); i++) {
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
}
return false;
};
function referenceBest(cards) {
let top = null;
for (let a = 0; a < 3; a++) for (let b = a + 1; b < 4; b++)
for (let c = b + 1; c < 5; c++) for (let d = c + 1; d < 6; d++)
for (let e = d + 1; e < 7; e++) {
const score = reference5([cards[a], cards[b], cards[c], cards[d], cards[e]]);
if (!top || worse(top, score)) top = score;
}
return top;
}
// A deck, and a deterministic shuffle — a failure has to be reproducible or it is
// just an anecdote about one CI run.
const DECK = [];
"23456789TJQKA".split("").forEach(r => "SHDC".split("").forEach(s => DECK.push(r + s)));
let seed = 20260904;
const random = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
test("twenty thousand random seven-card hands rank the same way", () => {
const deck = [...DECK];
for (let round = 0; round < 20000; round++) {
// Seven off the top of a shuffle of only what it needs.
const drawn = [];
for (let i = 0; i < 7; i++) {
const pick = i + Math.floor(random() * (deck.length - i));
[deck[i], deck[pick]] = [deck[pick], deck[i]];
drawn.push(deck[i]);
}
const mine = HandRank.best(drawn);
const theirs = referenceBest(drawn);
assert.deepEqual([mine.category, ...mine.tiebreak], theirs,
`disagreed on ${drawn.join(" ")}`);
}
});
test("and four thousand random pairs of hands are ordered the same way", () => {
// The categories agreeing is not the same as the comparison agreeing: this is
// the half that pays somebody.
const deck = [...DECK];
for (let round = 0; round < 4000; round++) {
const drawn = [];
for (let i = 0; i < 9; i++) {
const pick = i + Math.floor(random() * (deck.length - i));
[deck[i], deck[pick]] = [deck[pick], deck[i]];
drawn.push(deck[i]);
}
const board = drawn.slice(0, 5);
const one = [...board, drawn[5], drawn[6]];
const two = [...board, drawn[7], drawn[8]];
const mine = Math.sign(HandRank.compare(HandRank.best(one), HandRank.best(two)));
const a = referenceBest(one), b = referenceBest(two);
const theirs = worse(a, b) ? -1 : worse(b, a) ? 1 : 0;
assert.equal(mine, theirs,
`board ${board.join(" ")}: ${drawn[5]}${drawn[6]} vs ${drawn[7]}${drawn[8]}`);
}
});
});
describe("saying it in a sentence", () => {
// The verdict line reads "Alexandra wins $25 with ___", so the phrase has to carry
// its own article. Which article is not something the first letter can answer: it
// is "a full house" and "a flush", but "four of a kind" and "two pair" take none,
// and nobody has ever said "an ace high".
test("every category takes the article a table would give it", () => {
const said = str => HandRank.phrase(HandRank.evaluate5(hand(str)));
assert.deepEqual([
said("AS KS QS JS TS"),
said("9H 8H 7H 6H 5H"),
said("7C 7D 7H 7S 2C"),
said("KC KD KH 3S 3C"),
said("AD JD 8D 5D 2D"),
said("9C 8D 7H 6S 5C"),
said("QC QD QH 7S 2C"),
said("AC AD 8H 8S 4C"),
said("QC QD 9H 5S 2C"),
said("AC JD 8H 5S 2C"),
], [
"a royal flush",
"a straight flush, nine high",
"four of a kind, sevens",
"a full house, kings full of threes",
"a flush, ace high",
"a straight, nine high",
"three of a kind, queens",
"two pair, aces and eights",
"a pair of queens",
"ace high",
]);
});
test("with nothing to say it says something", () => {
assert.equal(HandRank.phrase(null), "the best hand");
});
});
+20
View File
@@ -15,9 +15,11 @@ function load(name) {
}
const Money = load("money");
const HandRank = load("handRank");
const PotCalculator = load("potCalculator");
const SplitCalculator = load("splitCalculator");
const Betting = load("betting");
const SettleUp = load("settleUp");
const HandHistoryExport = load("handHistoryExport");
// render.js exports { Render } rather than the namespace directly.
@@ -28,6 +30,19 @@ 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");
// The picker's slot bookkeeping — which slots it is filling, what is already dealt —
// is plain state work and testable here. Only render() and open() touch the DOM.
const CardPicker = load("cardPicker");
// The wake lock's rule about when a table needs the screen is plain state work too.
// Its acquire/release bookkeeping is testable here as well, against a stand-in for
// navigator.wakeLock — see wakeLock.test.js.
const WakeLock = load("wakeLock");
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -73,12 +88,17 @@ function contributions(state) {
module.exports = {
Money,
HandRank,
Render,
HandHistoryExport,
PotCalculator,
SplitCalculator,
SettleUp,
Betting,
HoldemState,
ActionBar,
CardPicker,
WakeLock,
makeTable,
chipsOnTable,
contributions,
+114
View File
@@ -0,0 +1,114 @@
// What happens when the saved table can't be read.
//
// An unreadable save can only become an empty table. What it must not do is become
// one silently and then get written over by the first edit.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState } = require("./load");
function withStored(value, { readOnly = false } = {}) {
const store = new Map();
if (value !== undefined) store.set(HoldemState.STORAGE_KEY, value);
globalThis.localStorage = {
getItem: key => (store.has(key) ? store.get(key) : null),
setItem(key, val) {
if (readOnly) throw new Error("QuotaExceededError");
store.set(key, val);
},
removeItem: key => store.delete(key),
};
return store;
}
test.beforeEach(() => {
HoldemState.loadFailed = false;
HoldemState.onLoadFailed = null;
});
test.after(() => {
delete globalThis.localStorage;
HoldemState.loadFailed = false;
HoldemState.onLoadFailed = null;
});
test("nothing stored is a new table, not a failure", () => {
withStored(undefined);
const state = HoldemState.load();
assert.equal(HoldemState.loadFailed, false);
assert.ok(Array.isArray(state.seats));
});
test("a good save loads and reports nothing", () => {
const store = withStored(undefined);
const original = HoldemState.createDefault();
original.handNumber = 34;
HoldemState.save(original);
const state = HoldemState.load();
assert.equal(HoldemState.loadFailed, false);
assert.equal(state.handNumber, 34);
assert.equal(store.has(HoldemState.RECOVERY_KEY), false);
});
test("a truncated save is reported rather than silently becoming an empty table", () => {
const good = JSON.stringify(HoldemState.createDefault());
withStored(good.slice(0, Math.floor(good.length * 0.7)));
const state = HoldemState.load();
assert.equal(HoldemState.loadFailed, true);
assert.ok(Array.isArray(state.seats), "it still hands back a usable table");
});
test("valid JSON of the wrong shape counts as unreadable too", () => {
withStored(JSON.stringify({ hello: "world" }));
HoldemState.load();
assert.equal(HoldemState.loadFailed, true);
});
test("the unreadable copy is kept instead of being written over", () => {
// This is the half that matters: whatever a person could have picked out of the
// raw JSON used to be gone the moment the dealer touched a seat.
const broken = '{"seats":[{"name":"Ann","stack":250},{"name":"Ben"';
const store = withStored(broken);
HoldemState.load();
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
});
test("a later save doesn't touch the copy that was set aside", () => {
const broken = '{"seats":[{"name":"Ann"';
const store = withStored(broken);
const state = HoldemState.load();
state.handNumber = 2;
HoldemState.save(state);
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
assert.notEqual(store.get(HoldemState.STORAGE_KEY), broken);
});
test("the listener fires once, not on every read", () => {
withStored("{not json");
let told = 0;
HoldemState.onLoadFailed = () => { told++; };
HoldemState.load();
HoldemState.load();
HoldemState.load();
assert.equal(told, 1);
});
test("no room to keep the copy is not a crash", () => {
// Storage that refuses writes is exactly the storage most likely to hand back a
// half-written value in the first place.
withStored("{not json", { readOnly: true });
let state;
assert.doesNotThrow(() => { state = HoldemState.load(); });
assert.ok(Array.isArray(state.seats));
assert.equal(HoldemState.loadFailed, true);
});
+155
View File
@@ -0,0 +1,155 @@
// Taking a hand back.
//
// The rulings sheet in this tool has told dealers to redeal since the day it was
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one of
// the first two cards exposed — and the tool had no way to do it. The only control that
// ended a live hand was Next Hand, which says out loud that it takes the money off the
// table without paying anybody. That is right for an abandoned pot and wrong for a
// misdeal, where the blinds and the calls belong to the people who put them in.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const dealt = (extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10, dealerSeatIndex: 0, ...extra });
Betting.beginHand(state);
return state;
};
describe("the money", () => {
test("goes back to the people who put it in", () => {
const state = dealt();
const before = state.seats.map(p => Betting.stackOf(state, p) + Betting.committed(p));
Betting.takeHandBack(state);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("including calls and raises, not just the blinds", () => {
const state = dealt();
Betting.raiseTo(state, state.actingSeatIndex, 40);
Betting.call(state, state.actingSeatIndex);
assert.ok(Betting.potSoFar(state) > 50, "there is real money in the middle");
const taken = Betting.takeHandBack(state);
assert.equal(taken.total, 95, "blinds and both wagers");
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
});
test("and across streets, once chips have been swept into hand totals", () => {
const state = dealt();
[3, 4, 5, 0, 1, 2].forEach(seat => {
if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
});
Betting.closeStreet(state);
assert.ok(state.seats.some(p => (Number(p.amountOverride) || 0) > 0), "the flop's money is banked");
Betting.takeHandBack(state);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
assert.deepEqual(state.seats.map(p => p.amountOverride), [null, null, null, null, null, null]);
});
test("the table has exactly the chips it started with", () => {
const fresh = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
const total = chipsOnTable(fresh);
const state = dealt();
Betting.raiseTo(state, state.actingSeatIndex, 40);
Betting.takeHandBack(state);
assert.equal(chipsOnTable(state), total);
});
test("nothing is thrown away — the discard pile doesn't move", () => {
const state = dealt();
state.discarded = 8;
Betting.takeHandBack(state);
assert.equal(state.discarded, 8, "a misdeal is not an abandoned pot");
});
test("it says what went back and to whom", () => {
const state = dealt();
const taken = Betting.takeHandBack(state);
assert.equal(taken.returned.length, 2, "the two blinds");
assert.deepEqual(taken.returned.map(r => r.amount).sort((a, b) => a - b), [5, 10]);
assert.ok(taken.returned.every(r => r.name), "named, because the dealer says it out loud");
});
});
describe("the table afterwards", () => {
test("is waiting to deal the same hand again", () => {
const state = dealt();
Betting.takeHandBack(state);
assert.equal(state.bettingOpen, false, "the dealer says when");
assert.equal(state.street, "preflop");
assert.equal(state.actingSeatIndex, null);
assert.equal(state.currentBet, 0);
});
test("keeps the button where it was — a misdeal is not the next hand", () => {
const state = dealt({ dealerSeatIndex: 3 });
const handNumber = state.handNumber;
Betting.takeHandBack(state);
assert.equal(state.dealerSeatIndex, 3);
assert.equal(state.handNumber, handNumber, "no hand has been played");
assert.equal((state.handHistory || []).length, 0, "and none is logged");
});
test("keeps the straddle, which was announced and still stands", () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
state.straddleCount = 1;
Betting.beginHand(state);
assert.deepEqual(state.postedStraddles, [3]);
Betting.takeHandBack(state);
assert.equal(state.straddleCount, 1, "still on for the redeal");
assert.deepEqual(state.postedStraddles, [], "but nothing is out there yet");
Betting.beginHand(state);
assert.deepEqual(state.postedStraddles, [3], "and it goes out again");
});
test("clears the cards and the folds, because none of it happened", () => {
const state = dealt();
state.seats[3].cards = ["As", "Kd"];
Betting.fold(state, 3);
Betting.takeHandBack(state);
assert.deepEqual(state.seats[3].cards, ["", ""]);
assert.equal(state.seats[3].folded, false);
assert.ok(state.seats.every(p => !p.hasActed));
});
test("deals again cleanly, with the same blinds on the same players", () => {
const state = dealt();
const first = state.seats.map(p => Betting.committed(p));
Betting.takeHandBack(state);
Betting.beginHand(state);
assert.deepEqual(state.seats.map(p => Betting.committed(p)), first);
});
});
describe("the bank still balances", () => {
test("through a misdeal and the redeal after it", () => {
const state = dealt();
state.seats.forEach(p => { p.buyIns = 200; });
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.raiseTo(state, state.actingSeatIndex, 60);
Betting.takeHandBack(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.beginHand(state);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, true);
assert.equal(bank.cashIn, 1200);
});
});
describe("taking back a hand nobody had money in", () => {
test("is harmless: no blinds, nothing moves", () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 0, bigBlind: 0 });
Betting.beginHand(state);
const taken = Betting.takeHandBack(state);
assert.equal(taken.total, 0);
assert.deepEqual(taken.returned, []);
assert.equal(state.bettingOpen, false);
});
});
+18 -3
View File
@@ -96,11 +96,26 @@ describe("the exported CSV", () => {
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
});
// Standings rows: every column after the name has to be numeric.
// Standings rows: the money columns have to be numeric. Named rather than
// "everything after the first", which is what this used to say — the row now
// ends with a Notes column carrying whatever the dealer typed against the seat,
// and that is text by definition. The point of this test is that a column
// somebody totals adds up, so it checks the columns somebody totals.
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
const header = splitRow(lines[standingsStart]);
const moneyColumns = ["Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]
.map(name => {
const index = header.indexOf(name);
assert.ok(index > 0, `the standings header should still have a ${name} column`);
return index;
});
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
splitRow(line).slice(1).forEach(cell => {
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
const cells = splitRow(line);
moneyColumns.forEach(index => {
const cell = cells[index];
assert.ok(cell !== "" && !Number.isNaN(Number(cell)),
`standings cell "${cell}" in column ${header[index]} isn't a number`);
});
});
});
+133
View File
@@ -0,0 +1,133 @@
// One question, one answer.
//
// The round before this found the bank check and the standings reading `seat.stack`
// where the felt reads the table's starting stack — two parts of the tool disagreeing
// about how much money is on the table. This is the sweep that followed: everywhere
// else the same two questions get asked, asked the same way.
//
// "What is this seat holding?" -> HoldemState.chipsHeldBy
// "Is anybody in this chair?" -> HoldemState.seatInPlay
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, SettleUp } = require("./load");
const openedOnTheDefault = (stack = 200) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 6);
state.defaultStack = stack;
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = stack;
});
return state;
};
describe("what a seat is holding", () => {
test("is the same number the felt plays with", () => {
const state = openedOnTheDefault();
state.seats.forEach((player, i) => {
if (i > 2) return;
assert.equal(HoldemState.chipsHeldBy(state, player), Betting.stackOf(state, player));
});
});
test("and is nothing at all for an empty chair", () => {
const state = openedOnTheDefault();
// Betting's own answer for an empty chair is the table default, because it is
// only ever asked about seats that are in a hand. The money side has to ask
// about all ten chairs, so it checks whether anybody is in them first.
assert.equal(Betting.stackOf(state, state.seats[5]), 200);
assert.equal(HoldemState.chipsHeldBy(state, state.seats[5]), 0);
});
test("counts a stack the dealer typed over the table's default", () => {
const state = openedOnTheDefault();
state.seats[0].stack = 75;
assert.equal(HoldemState.chipsHeldBy(state, state.seats[0]), 75);
});
});
describe("is anybody in this chair", () => {
const empty = () => HoldemState.createDefault().seats[0];
test("an untouched chair is empty", () => {
assert.equal(HoldemState.seatInPlay(empty()), false);
assert.equal(HoldemState.seatInPlay(null), false);
});
test("a name, chips, cash, a bet or a dealt hand all count", () => {
[
["a name", p => { p.name = "Ann"; }],
["chips", p => { p.stack = 50; }],
["cash taken", p => { p.buyIns = 100; }],
["a bet in front of them", p => { p.committed = 5; }],
["money already swept in", p => { p.amountOverride = 20; }],
["a hand dealt to them", p => { p.inHand = true; }],
].forEach(([what, mark]) => {
const seat = empty();
mark(seat);
assert.equal(HoldemState.seatInPlay(seat), true, what);
});
});
test("whitespace is not a name", () => {
const seat = empty();
seat.name = " ";
assert.equal(HoldemState.seatInPlay(seat), false);
});
});
describe("the parts that used to disagree", () => {
test("the open seats and the seat count see the same empty chairs", () => {
const state = openedOnTheDefault();
// Three players, six chairs.
assert.deepEqual(HoldemState.openSeats(state), [3, 4, 5]);
const occupied = state.seats.filter(p => HoldemState.seatInPlay(p)).length;
assert.equal(occupied + HoldemState.openSeats(state).length, state.seats.length);
});
test("a chair holding only a dealt hand is not offered to the waiting list", () => {
// The old test looked at name, buy-ins and stack. A seat marked in-hand with
// none of those would have been handed to the next person in the queue, on top
// of whoever was already playing it.
const state = openedOnTheDefault();
state.seats[4].inHand = true;
assert.deepEqual(HoldemState.openSeats(state), [3, 5]);
HoldemState.addToWaitingList(state, "Priya");
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 4);
assert.equal(result.ok, false);
});
test("the bank, the standings and the settle-up all price the night the same way", () => {
const state = openedOnTheDefault();
state.seats[0].stack = 320; // Ann is up
state.seats[1].stack = 80; // Ben is down
const bank = HoldemState.bankReconciliation(state);
const standings = HoldemState.sessionStandings(state);
const chipsFromStandings = standings
.filter(row => !row.cashedOut)
.reduce((sum, row) => sum + row.chipsInFront, 0);
assert.equal(bank.chipsInPlay, chipsFromStandings, "same chips on the table");
assert.equal(bank.balanced, true);
// And the payments the dealer is told to make add up to the same money.
const plan = SettleUp.plan(standings);
const owed = standings.reduce((sum, row) => sum + Math.max(0, row.cashPosition), 0);
const paid = plan.payments.reduce((sum, row) => sum + row.amount, 0);
assert.equal(paid + (plan.shortBy || 0) - (plan.overBy || 0), owed);
});
test("a player still holding the table's starting stack is in all three", () => {
const state = openedOnTheDefault();
const standings = HoldemState.sessionStandings(state);
assert.equal(standings.length, 3);
assert.deepEqual(standings.map(row => row.chipsInFront), [200, 200, 200]);
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
assert.deepEqual(SettleUp.plan(standings).payments, [], "everyone is even, so nobody pays anybody");
});
});
+100
View File
@@ -0,0 +1,100 @@
// Noticing that the same table is open twice.
//
// Two tabs each hold their own copy in memory and each save writes the whole thing,
// so the second one to act throws the first one's work away. This doesn't merge them
// — replacing a table under a dealer's hands mid-hand would be its own hazard — it
// notices, which is what lets them decide.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState } = require("./load");
function withWindow() {
const listeners = [];
globalThis.window = {
addEventListener(type, fn) { if (type === "storage") listeners.push(fn); },
removeEventListener(type, fn) {
const i = listeners.indexOf(fn);
if (i >= 0) listeners.splice(i, 1);
},
};
return {
listeners,
fire(key) { listeners.forEach(fn => fn({ key })); },
};
}
test.beforeEach(() => {
HoldemState.otherTabActive = false;
HoldemState.onOtherTab = null;
HoldemState.tabWatcher = null;
});
test.after(() => {
delete globalThis.window;
HoldemState.otherTabActive = false;
HoldemState.onOtherTab = null;
HoldemState.tabWatcher = null;
});
test("a write to this table from elsewhere is noticed", () => {
const win = withWindow();
let told = 0;
HoldemState.onOtherTab = () => { told++; };
HoldemState.watchOtherTabs();
win.fire(HoldemState.STORAGE_KEY);
assert.equal(HoldemState.otherTabActive, true);
assert.equal(told, 1);
});
test("somebody else's storage key is not our business", () => {
const win = withWindow();
let told = 0;
HoldemState.onOtherTab = () => { told++; };
HoldemState.watchOtherTabs();
win.fire("some.other.app.state");
win.fire(HoldemState.RECOVERY_KEY);
assert.equal(HoldemState.otherTabActive, false);
assert.equal(told, 0);
});
test("told once, however many times the other tab acts", () => {
const win = withWindow();
let told = 0;
HoldemState.onOtherTab = () => { told++; };
HoldemState.watchOtherTabs();
for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY);
assert.equal(told, 1);
});
test("watching twice doesn't listen twice", () => {
const win = withWindow();
HoldemState.watchOtherTabs();
HoldemState.watchOtherTabs();
HoldemState.watchOtherTabs();
assert.equal(win.listeners.length, 1);
});
test("no listener set is not an error", () => {
const win = withWindow();
HoldemState.onOtherTab = null;
HoldemState.watchOtherTabs();
assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY));
assert.equal(HoldemState.otherTabActive, true);
});
test("no window at all is not an error", () => {
// The same modules are loaded in Node by the test harness.
delete globalThis.window;
HoldemState.tabWatcher = null;
assert.doesNotThrow(() => HoldemState.watchOtherTabs());
assert.equal(HoldemState.otherTabActive, false);
});
+218
View File
@@ -0,0 +1,218 @@
// What a shut sidebar panel says about itself.
//
// The sidebar is eight <details>, every one exactly 60px tall with nothing in it but
// its own name — 480px of a phone's screen and 480px of a 660px desktop column, saying
// "Standings", "Hands", "Waiting list" and nothing else. Whether the box balances,
// whether anyone is waiting for a seat, how many hands have been played: the page had
// all three worked out and charged a tap for each of them, on rows two thirds empty.
//
// So each one now carries the fact you would have opened it for. Two rules hold this
// together, and both are checked here.
//
// A glance appears only when it has something a dealer would act on. Eight rows that
// always say something are eight rows that say nothing again, and a row reading
// "Nobody waiting" all night is the clutter this went in to remove.
//
// And a glance never says something the panel under it would contradict. It is the
// panel's own sentence with the explanation taken off, so it has to be read off the
// same state — never off the panel's DOM, which for a shut <details> isn't rendered
// at all.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, HoldemState, makeTable } = require("./load");
const glances = state => Render.panelGlances(state);
const textOf = (state, id) => (glances(state)[id] || {}).text;
// A table part-way through a night: money on it, so the bank check has an opinion.
const night = () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 200,
smallBlind: 5, bigBlind: 10 });
state.seats.slice(0, 5).forEach(p => { p.buyIns = 200; });
return state;
};
describe("the rows that always have something to say", () => {
test("the table's own shape and stakes", () => {
assert.equal(textOf(night(), "glanceTableSetup"), "9 seats · $5/$10");
});
test("and the stakes drop out rather than reading as free", () => {
// A table set up with no blinds yet is a real state — it is what the panel
// opens on — and "9 seats · $0/$0" would be a claim about the game rather
// than an admission that nobody has said yet.
const state = night();
state.smallBlind = 0;
state.bigBlind = 0;
assert.equal(textOf(state, "glanceTableSetup"), "9 seats");
});
test("the chip values, as a range rather than a count of rows", () => {
// "Are the values right for tonight's set" is the question this answers.
// Five colours is the answer to a different one.
assert.equal(textOf(night(), "glanceChips"), "5 colours · $1–$500");
});
test("with a chip nobody has priced yet left out of the range", () => {
// A blank row in the legend is a chip the dealer hasn't got to, not a
// zero-dollar chip — and letting it in would read the range as starting at $0.
const state = night();
state.chipLegend.push({ id: "pink", color: "#ff88aa", value: 0 });
assert.equal(textOf(state, "glanceChips"), "5 colours · $1–$500");
});
test("and one chip is a value, not a range of one", () => {
const state = night();
state.chipLegend = [{ id: "red", color: "#c0392b", value: 5 }];
assert.equal(textOf(state, "glanceChips"), "1 colour · $5");
});
});
describe("the bank check, which is the one worth a colour", () => {
test("says so when the cash covers the chips", () => {
const state = night();
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Balanced");
assert.equal(tone, "ok");
});
test("and names the gap when it doesn't", () => {
// Chips on the table that nobody paid for. The glance is the alarm and the
// amount; which seat is holding them is a paragraph, and that is the half
// that stays behind the tap.
const state = night();
state.seats[0].buyIns = 100;
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Off by $100");
assert.equal(tone, "warn");
});
test("the same way round when the cash is the side that's over", () => {
// One word covers both signs on purpose: the number and the alarm are what a
// dealer acts on, and "short" against "over" is a direction they would have to
// hold in their head to read. The panel says which way in a sentence.
const state = night();
state.seats[0].buyIns = 400;
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Off by $200");
assert.equal(tone, "warn");
});
test("and stays quiet over a table nobody has bought into", () => {
// The panel hides its own check here for the same reason. A glance reading
// "Balanced" over an empty table is answering for a count never taken.
const state = makeTable({ seatCount: 9, seated: [], smallBlind: 5, bigBlind: 10 });
state.seats.forEach(p => { p.stack = null; p.buyIns = 0; });
assert.equal(textOf(state, "glanceStandings"), "");
assert.equal(glances(state).glanceStandings.tone, "");
});
test("and agrees with the sentence the panel would print", () => {
// The two are read off one call apiece and must never disagree — a glance
// saying "Balanced" over a panel saying "$40 more in chips than cash taken"
// is worse than no glance at all.
[0, 100, 400].forEach(buyIn => {
const state = night();
state.seats[0].buyIns = buyIn;
const bank = HoldemState.bankReconciliation(state);
const { text, tone } = glances(state).glanceStandings;
assert.equal(tone === "ok", bank.balanced, `buy-in ${buyIn}`);
if (!bank.balanced) {
assert.ok(text.includes(String(Math.abs(bank.difference))),
`${text} doesn't carry the panel's own ${bank.difference}`);
}
});
});
});
describe("the rows that stay quiet until they have news", () => {
test("nobody waiting, no note, no hands played yet", () => {
const state = night();
assert.equal(textOf(state, "glanceWaiting"), "");
assert.equal(textOf(state, "glanceHands"), "");
assert.equal(textOf(state, "glanceNotes"), "");
});
test("and speak up the moment they do", () => {
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }, { id: "w2", name: "Owen" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
assert.equal(textOf(state, "glanceWaiting"), "2 waiting");
assert.equal(textOf(state, "glanceHands"), "1 hand");
});
test("counting one of something as one of it", () => {
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] },
{ handNumber: 2, pot: 80, payouts: [], ledger: [] }];
assert.equal(textOf(state, "glanceWaiting"), "1 waiting");
assert.equal(textOf(state, "glanceHands"), "2 hands");
});
test("and a note shows which note it is, not that there is one", () => {
// "Has notes" costs a tap to learn anything, which is the whole failure this
// is fixing. The text is what identifies it; the row's own width ellipsises.
const state = night();
state.notes = "Sam owes Dee $20 from last night";
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20 from last night");
});
test("reading past the blank lines a note gets typed under", () => {
// Notes get written as a list — a heading, a blank line, then the thing. The
// first line of the string is regularly not the first line of the note.
const state = night();
state.notes = "\n \n Sam owes Dee $20\nand Ted owes Al $5";
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20");
});
test("and a note of nothing but whitespace is no note", () => {
const state = night();
state.notes = "\n\n \t \n";
assert.equal(textOf(state, "glanceNotes"), "");
});
});
describe("what the row is allowed to be", () => {
const ROWS = ["glanceTableSetup", "glanceStandings", "glanceWaiting",
"glanceHands", "glanceChips", "glanceNotes"];
test("every glance the sidebar renders is one this knows about", () => {
// The list drives the DOM writes, so a glance computed and never listed is a
// row that silently keeps its old text for the rest of the night.
const state = night();
assert.deepEqual(Object.keys(glances(state)).sort(), [...ROWS].sort());
assert.deepEqual([...Render.PANEL_GLANCES].sort(), [...ROWS].sort());
});
test("and none of them is a paragraph", () => {
// The row is 44px of a 320px column with a label already on it. Anything
// long enough to need the panel's room belongs in the panel.
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
ROWS.filter(id => id !== "glanceNotes").forEach(id => {
const text = textOf(state, id);
assert.ok(text.length <= 24, `${id} is ${text.length} chars: "${text}"`);
assert.ok(!text.includes("\n"), `${id} has a line break in it`);
});
});
test("including a note, which is trimmed to one line and left to the ellipsis", () => {
// The one glance whose length isn't ours to choose. It must still be a single
// line — a second one would make the row taller, and the row is what holds the
// sidebar to eight equal slabs.
const state = night();
state.notes = "x".repeat(400) + "\nsecond line";
const text = textOf(state, "glanceNotes");
assert.ok(!text.includes("\n"));
assert.equal(text, "x".repeat(400));
});
test("and a state that isn't there yet asks for nothing", () => {
// renderAll runs before the first load has finished on a cold open.
assert.deepEqual(Render.panelGlances(null), {});
assert.deepEqual(Render.panelGlances(undefined), {});
});
});
+100
View File
@@ -0,0 +1,100 @@
// Just enough PNG to read pixels back out of a screenshot.
//
// The contrast audit needs to know what colour is actually behind a piece of text, and
// the only honest answer is the one the browser painted. Walking the DOM and
// compositing `background-color` up the ancestors gets it wrong wherever a gradient,
// a shadow or a border is doing the painting: tried that first, and it reported the
// gold dealer button as black-on-black at Lc 0.4 — a fault that does not exist, on an
// element anyone can see is legible. Sampling the screenshot cannot make that mistake.
//
// zlib is in Node, so this needs no dependency. Screenshots are 8-bit and
// non-interlaced; anything else throws rather than returning quietly wrong pixels.
const zlib = require("node:zlib");
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function paeth(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
return pb <= pc ? b : c;
}
function decode(buffer) {
if (!buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG");
let width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
const idat = [];
let at = 8;
while (at < buffer.length) {
const length = buffer.readUInt32BE(at);
const type = buffer.toString("ascii", at + 4, at + 8);
const data = buffer.subarray(at + 8, at + 8 + length);
at += 12 + length;
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
depth = data[8];
colorType = data[9];
interlace = data[12];
} else if (type === "IDAT") {
idat.push(data);
} else if (type === "IEND") {
break;
}
}
if (depth !== 8) throw new Error(`unsupported bit depth ${depth}`);
if (interlace !== 0) throw new Error("interlaced PNG not supported");
if (colorType !== 2 && colorType !== 6) throw new Error(`unsupported colour type ${colorType}`);
const channels = colorType === 6 ? 4 : 3;
const raw = zlib.inflateSync(Buffer.concat(idat));
const stride = width * channels;
const out = Buffer.alloc(width * height * 4);
let prior = Buffer.alloc(stride);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
for (let i = 0; i < stride; i++) {
const left = i >= channels ? line[i - channels] : 0;
const up = prior[i];
const upLeft = i >= channels ? prior[i - channels] : 0;
switch (filter) {
case 0: break;
case 1: line[i] = (line[i] + left) & 0xff; break;
case 2: line[i] = (line[i] + up) & 0xff; break;
case 3: line[i] = (line[i] + ((left + up) >> 1)) & 0xff; break;
case 4: line[i] = (line[i] + paeth(left, up, upLeft)) & 0xff; break;
default: throw new Error(`unknown filter ${filter} on row ${y}`);
}
}
for (let x = 0; x < width; x++) {
const from = x * channels;
const to = (y * width + x) * 4;
out[to] = line[from];
out[to + 1] = line[from + 1];
out[to + 2] = line[from + 2];
out[to + 3] = channels === 4 ? line[from + 3] : 255;
}
prior = line;
}
return { width, height, data: out };
}
// Nearest pixel, clamped. A rect can sit a hair outside the shot after rounding, and
// clamping is the right answer there rather than an exception or a black pixel.
function pixelAt(image, x, y) {
const px = Math.min(image.width - 1, Math.max(0, Math.round(x)));
const py = Math.min(image.height - 1, Math.max(0, Math.round(y)));
const at = (py * image.width + px) * 4;
return [image.data[at], image.data[at + 1], image.data[at + 2]];
}
module.exports = { decode, pixelAt };
+178
View File
@@ -0,0 +1,178 @@
// The screenshot reader, tested by building PNGs whose pixels are already known.
//
// This matters more than it looks. If a filter is decoded wrong the pixels are still
// plausible colours — slightly off greys and greens, not obvious garbage — and the
// contrast audit would report confident, wrong numbers about a page nobody would think
// to re-check. So every filter type gets encoded here and read back.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const zlib = require("node:zlib");
const { decode, pixelAt } = require("./png");
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const head = Buffer.alloc(8);
head.writeUInt32BE(data.length, 0);
head.write(type, 4, "ascii");
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), data])), 0);
return Buffer.concat([head, data, crc]);
}
function paeth(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
return pb <= pc ? b : c;
}
// pixels: [y][x] = [r,g,b(,a)]. filter is applied to every row, which is legal PNG and
// is exactly how each decoder branch gets exercised.
function encode(pixels, filter = 0, channels = 4) {
const height = pixels.length, width = pixels[0].length;
const stride = width * channels;
const rows = [];
let prior = Buffer.alloc(stride);
for (let y = 0; y < height; y++) {
const raw = Buffer.alloc(stride);
for (let x = 0; x < width; x++) {
for (let c = 0; c < channels; c++) {
raw[x * channels + c] = pixels[y][x][c] !== undefined ? pixels[y][x][c] : 255;
}
}
const filtered = Buffer.alloc(stride);
for (let i = 0; i < stride; i++) {
const left = i >= channels ? raw[i - channels] : 0;
const up = prior[i];
const upLeft = i >= channels ? prior[i - channels] : 0;
switch (filter) {
case 0: filtered[i] = raw[i]; break;
case 1: filtered[i] = (raw[i] - left) & 0xff; break;
case 2: filtered[i] = (raw[i] - up) & 0xff; break;
case 3: filtered[i] = (raw[i] - ((left + up) >> 1)) & 0xff; break;
case 4: filtered[i] = (raw[i] - paeth(left, up, upLeft)) & 0xff; break;
default: throw new Error("bad filter");
}
}
rows.push(Buffer.concat([Buffer.from([filter]), filtered]));
prior = raw;
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = channels === 4 ? 6 : 2;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows))),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Deliberately awkward: neighbouring values that differ, so a filter decoded with the
// wrong neighbour produces a different answer rather than the same one by luck.
const PIXELS = [
[[20, 30, 40], [200, 10, 90], [18, 18, 18], [255, 255, 255]],
[[0, 0, 0], [28, 108, 60], [108, 68, 39], [1, 2, 3]],
[[240, 217, 168], [47, 122, 77], [200, 200, 200], [17, 17, 17]],
];
describe("every filter type reads back exactly", () => {
[0, 1, 2, 3, 4].forEach(filter => {
test(`filter ${filter}`, () => {
const image = decode(encode(PIXELS, filter));
assert.equal(image.width, 4);
assert.equal(image.height, 3);
for (let y = 0; y < PIXELS.length; y++) {
for (let x = 0; x < PIXELS[y].length; x++) {
assert.deepEqual(pixelAt(image, x, y), PIXELS[y][x],
`pixel ${x},${y} under filter ${filter}`);
}
}
});
});
test("and a three-channel image reads back too", () => {
const image = decode(encode(PIXELS, 4, 3));
assert.deepEqual(pixelAt(image, 1, 2), [47, 122, 77]);
});
test("including where Paeth has to choose between close neighbours", () => {
// Written first to catch paeth(left, up, ...) decoded as paeth(up, left, ...),
// on the assumption that the two differ on a tie. They don't, anywhere: the
// predictor is symmetric in its first two arguments, checked over all 16,777,216
// byte triples. The reason is that a tie between pa and pb requires
// left + up == 2 * upLeft, and that is exactly the condition making pc zero — so
// every tie is resolved by returning upLeft, whichever order the other two
// arrived in. Swapping them is a no-op and no test can catch it, which is worth
// knowing rather than papering over with a fixture that appears to.
//
// What this does catch is the mistake that is actually available: reading the
// wrong neighbour. The values sit close together so a predictor that reaches one
// pixel off produces a different answer rather than the same one by luck.
const CLOSE = [
[[100, 10, 20], [110, 40, 25]],
[[90, 70, 30], [55, 95, 35]],
];
const image = decode(encode(CLOSE, 4));
assert.deepEqual(pixelAt(image, 1, 1), [55, 95, 35]);
assert.deepEqual(pixelAt(image, 0, 1), [90, 70, 30]);
assert.deepEqual(pixelAt(image, 1, 0), [110, 40, 25]);
});
});
describe("sampling", () => {
const image = decode(encode(PIXELS, 0));
test("rounds to the nearest pixel", () => {
assert.deepEqual(pixelAt(image, 0.4, 0.4), [20, 30, 40]);
assert.deepEqual(pixelAt(image, 0.6, 0.4), [200, 10, 90]);
});
test("clamps rather than throwing or reading black", () => {
// A rect can sit a hair outside the shot once it's been rounded, and a silent
// black pixel there would read as a contrast failure that isn't one.
assert.deepEqual(pixelAt(image, -5, -5), [20, 30, 40]);
assert.deepEqual(pixelAt(image, 999, 999), [17, 17, 17]);
});
});
describe("what it refuses", () => {
test("something that isn't a PNG at all", () => {
assert.throws(() => decode(Buffer.from("not a png at all, honestly")), /not a PNG/);
});
test("an interlaced one, rather than returning scrambled pixels", () => {
const png = encode(PIXELS, 0);
// Byte 12 of the IHDR data: 8 signature + 8 chunk header + 12 = interlace flag.
const broken = Buffer.from(png);
broken[8 + 8 + 12] = 1;
assert.throws(() => decode(broken), /interlaced/);
});
test("a colour type it cannot read", () => {
const broken = Buffer.from(encode(PIXELS, 0));
broken[8 + 8 + 9] = 3; // palette
assert.throws(() => decode(broken), /colour type/);
});
});
+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;
});
});
+127
View File
@@ -0,0 +1,127 @@
// Who can win each pot, said in the fewest names that answer it.
//
// The breakdown used to list every eligible player for every pot. Pot eligibility is
// nested — potCalculator.test.js checks that across 20,000 tables — so the main pot's
// line was every name at the table and each side pot repeated it minus one. The lines
// that were too long to fit were exactly the ones nobody needed spelled out, and the
// short ones at the bottom were the side pots a dealer actually announces by name.
//
// Measured on an iPhone 14 before this: one all-in five-handed already clipped the main
// pot, and twelve-handed the first row wanted 521px of a 254px slot, with no count and
// no title — a dealer reading "Alexandra…" could not tell whether three people or
// eleven were eligible, which is the number the showdown turns on.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
// Names are "P<id>" so a test reads as ids and the joining is still exercised.
const nameFor = id => `P${id}`;
const say = (eligible, everyone) => Render.describeEligible(eligible, everyone, nameFor);
describe("the eligible line", () => {
test("says so plainly when the whole table can win it", () => {
// The main pot, every hand. Twelve names to say "nobody is excluded yet" was
// the single longest line in the component and the least informative.
assert.equal(say(["a", "b", "c"], ["a", "b", "c"]), "all 3");
assert.equal(say(["a"], ["a"]), "all 1");
});
test("names who is out when that is the shorter list", () => {
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
assert.equal(say(["b", "c", "d", "e", "f", "g", "h"], table), "7 in, not Pa");
assert.equal(say(["c", "d", "e", "f", "g", "h"], table), "6 in, not Pa and Pb");
assert.equal(say(["d", "e", "f", "g", "h"], table), "5 in, not Pa, Pb and Pc");
});
test("names who is in when that is the shorter list", () => {
// The far side pots, which are the ones a dealer announces by name: "side pot
// is 80 between Ben and Carlos".
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
assert.equal(say(["g", "h"], table), "2 in, Pg and Ph");
assert.equal(say(["h"], table), "1 in, Ph");
assert.equal(say(["f", "g", "h"], table), "3 in, Pf, Pg and Ph");
});
test("and when the two are the same length, says who is in", () => {
// A tie is not a reason to make the dealer do the subtraction: "who can win
// this" is the question, so answer it directly when it costs nothing.
const table = ["a", "b", "c", "d"];
assert.equal(say(["c", "d"], table), "2 in, Pc and Pd");
});
test("leads with the count, because it is the tail that gets cut off", () => {
// .potLayerWho is right-aligned with an ellipsis, so a line too long for a phone
// loses its end. Every form has to begin with the number of hands to ask for.
const table = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"];
[table, ["c", "d", "e", "f", "g", "h", "i", "j", "k", "l"], ["k", "l"]]
.forEach(eligible => assert.match(say(eligible, table), /^(all \d+|\d+ in,)/,
`"${say(eligible, table)}" buries the count where a narrow screen can eat it`));
});
test("never spells out more than half the table", () => {
// The point of picking the shorter side. Nested eligibility makes the two lists
// complements, so this is a guarantee rather than a hope.
const table = Array.from({ length: 12 }, (_, i) => `s${i}`);
for (let inCount = 1; inCount <= 12; inCount++) {
const line = say(table.slice(0, inCount), table);
const named = (line.match(/P/g) || []).length;
assert.ok(named <= 6, `${inCount} of 12 named ${named} people: "${line}"`);
}
});
});
// A stub rather than a DOM: the file has no jsdom and does not want one. Enough of an
// element to answer what the row is made of.
const fakeDom = () => {
const make = () => ({
className: "", textContent: "", title: "", children: [],
classList: { add() {} },
appendChild(child) { this.children.push(child); return child; },
});
global.document = { createElement: make };
return () => { delete global.document; };
};
describe("the row it builds", () => {
const pots = [
{ label: "Main pot", amount: 300, eligible: ["a", "b", "c", "d"] },
{ label: "Side pot 1", amount: 150, eligible: ["c", "d"] },
];
const textOf = row => row.children.map(c => c.textContent);
test("carries the full list in a title, for anyone with a pointer", () => {
const undo = fakeDom();
try {
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
const who = wrap.children.map(row => row.children[2]);
assert.equal(who[0].title, "Pa, Pb, Pc, Pd");
assert.equal(who[1].title, "Pc, Pd");
// The visible text is the short form; the title is the long one. A tooltip
// is not a mechanism a keyboard reaches, which is why it is the extra and
// not the answer.
assert.equal(who[0].textContent, "all 4");
assert.equal(who[1].textContent, "2 in, Pc and Pd");
} finally { undo(); }
});
test("puts the label, the money and the people in that order", () => {
const undo = fakeDom();
try {
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
assert.deepEqual(textOf(wrap.children[0]), ["Main pot", "$300", "all 4"]);
} finally { undo(); }
});
test("and an uncalled bet still reads as money going back", () => {
// Not an eligibility question — nobody is competing for it — so it keeps its
// own wording and gets no title to expand.
const undo = fakeDom();
try {
const wrap = Render.buildPotBreakdown(pots, [{ id: "a", amount: 25 }], nameFor);
const row = wrap.children[2];
assert.deepEqual(textOf(row), ["Uncalled", "$25", "back to Pa"]);
assert.equal(row.children[2].title, "");
} finally { undo(); }
});
});
+229
View File
@@ -0,0 +1,229 @@
// The bar's pot list is not allowed to grow with the table.
//
// #actionBar is fixed over the felt, so every pixel it takes is a pixel the table
// loses — and this list took one row per all-in with no ceiling. Measured on a 360x640
// phone, five players idle: bar 151px. Six all-in: 303px. Twelve all-in: 411px, which
// is 64% of the screen, leaving 96px of felt to draw twelve seats in. A fixed overlay
// whose height depends on the state is a fault whatever it is showing, so the cap is
// checked here rather than left to whoever next adds an all-in to the audit sweep.
//
// The two ends survive because they are the two that are live. Side pots are paid from
// the newest one back down to the main pot, so the top layer is the next thing that
// happens and the main pot is the last, and those are the two a dealer says out loud
// while the chips are stacked — "main pot 7,200, side pot 4,500". The layers between
// are the middle of that count-down, wanted at showdown, which is the pot wizard, which
// passes no cap and still lists all of them.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, Money, PotCalculator } = require("./load");
const nameFor = id => `P${id}`;
// The same stub potBreakdown.test.js uses — enough of an element to say what the row
// is made of, and no jsdom in a suite that runs in a second.
const fakeDom = () => {
const make = () => ({
className: "", textContent: "", title: "", children: [],
classList: { add() {} },
appendChild(child) { this.children.push(child); return child; },
});
global.document = { createElement: make };
return () => { delete global.document; };
};
// A table where everyone is all-in for a different amount, which is the shape that
// makes the most pot layers — the worst case and the one that was measured. `count`
// players ladder into `count - 1` layers: two of them share a single undivided pot and
// the breakdown declines to draw at all, which is why nothing below three appears here.
const laddered = count => PotCalculator.layers(
Array.from({ length: count }, (_, i) => ({
id: `s${i}`, contribution: 100 + i * 130, folded: false,
})));
const build = (pots, options) => {
const undo = fakeDom();
try {
const wrap = Render.buildPotBreakdown(pots, [], nameFor, null, options);
return wrap.children.map(row => ({
className: row.className,
label: row.children[0].textContent,
amount: row.children[1].textContent,
who: row.children[2].textContent,
title: row.children[2].title,
}));
} finally { undo(); }
};
describe("the bar's copy of the pot list", () => {
test("stops growing once there are more pots than it has room for", () => {
// The property that matters: rows are a function of the cap, not of the table.
// Twelve-handed and six-handed have to render the same number of lines.
for (let players = 3; players <= 12; players++) {
const rows = build(laddered(players), { maxLayers: 4 });
assert.ok(rows.length <= 4,
`${players} all-in drew ${rows.length} rows; the bar is sized for 4`);
}
});
test("and the number of rows never depends on how many people are all-in", () => {
const wide = [8, 9, 10, 11, 12].map(n => build(laddered(n), { maxLayers: 4 }).length);
assert.deepEqual(wide, [3, 3, 3, 3, 3],
"past the cap every table should draw main, the elision and the top pot");
});
test("keeps the two pots a dealer actually announces", () => {
const pots = laddered(12);
const rows = build(pots, { maxLayers: 4 });
assert.equal(rows[0].label, pots[0].label, "the main pot is the last one paid");
assert.equal(rows[rows.length - 1].label, pots[pots.length - 1].label,
"the newest side pot is the first one paid");
});
test("and leaves them in the order the pots were made", () => {
const rows = build(laddered(12), { maxLayers: 4 });
assert.match(rows[1].className, /potLayerMore/,
"the elision belongs where the gap is, not appended after the list");
});
});
describe("the money in the rows that were elided", () => {
test("is still on the screen, added up", () => {
// The one thing a summary row must not do is lose chips. Whatever is drawn has
// to account for every pot, or a dealer counting the announcement against the
// chips in the middle finds a shortfall the tool caused.
for (let players = 3; players <= 12; players++) {
const pots = laddered(players);
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
const drawn = build(pots, { maxLayers: 4 })
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
assert.equal(drawn, total,
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
}
});
test("and says how many it stands for", () => {
const rows = build(laddered(12), { maxLayers: 4 });
const pots = laddered(12);
assert.equal(rows[1].label, `${pots.length - 2} more side pots`);
});
test("with the list itself in a title, for anyone with a pointer", () => {
const pots = laddered(12);
const rows = build(pots, { maxLayers: 4 });
pots.slice(1, -1).forEach(pot => {
assert.ok(rows[1].title.includes(pot.label),
`${pot.label} vanished without trace`);
assert.ok(rows[1].title.includes(Money.format(pot.amount)),
`${pot.label}'s amount vanished without trace`);
});
});
test("and points at where the whole list is", () => {
// The title is a pointer affordance, so it cannot be the answer on a phone. The
// visible text has to say where the rest lives.
assert.match(build(laddered(12), { maxLayers: 4 })[1].who, /resolve/i);
});
});
describe("when eliding would not buy anything", () => {
test("a list that fits is left alone", () => {
for (let players = 3; players <= 5; players++) {
const pots = laddered(players);
assert.ok(pots.length <= 4, "this loop is meant to cover the fitting sizes");
const rows = build(pots, { maxLayers: 4 });
assert.equal(rows.length, pots.length);
assert.deepEqual(rows.map(r => r.label), pots.map(p => p.label));
}
});
test("and a single middle layer is not replaced by a line saying there is one", () => {
// At exactly the cap the middle is one row, and swapping one row for one row is
// pure indirection: the dealer loses an amount and gains a sentence.
const pots = laddered(5);
assert.equal(pots.length, 4, "five all-in ladder into exactly the cap");
const rows = build(pots, { maxLayers: 4 });
assert.equal(rows.length, 4);
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
});
});
describe("the wizard, which has room", () => {
test("still gets every layer", () => {
// Same builder, no cap — this is the announcement read out before anybody shows
// a hand, and it is in a dialog that scrolls. Capping it there would hide the
// pots at the moment they are being paid.
const pots = laddered(12);
const rows = build(pots, undefined);
assert.equal(rows.length, pots.length);
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
});
});
// ---- And smaller still once nobody can act -----------------------------------
//
// The list is decision support. A player working out whether to call is owed the side
// pot they would be calling into, and that is worth three rows of a fixed bar.
//
// Once everyone is all-in there is no decision left to support: the only thing that
// happens for the rest of the hand is the dealer turning cards. The list went on
// standing there anyway, and it is not free — measured at 360x640 with twelve players
// all-in, the bar was 267px of a 640px window. That is 42%, against the sweep's own
// "persistent chrome under 30%", and the felt pays it on the exact table shape that has
// the most seats to fit into what is left.
//
// So during a run-out it collapses to the main pot and one line for everything else.
// The main pot stays because it is the number a dealer says out loud; the layering is
// what the pot wizard is for, and the summary row has always said so in as many words.
describe("the list during a run-out, when nobody has a decision left", () => {
test("is two rows however many people are all-in", () => {
const rows = [3, 6, 9, 12].map(n => build(laddered(n), { maxLayers: 4, summarise: true }).length);
assert.deepEqual(rows, [2, 2, 2, 2]);
});
test("and that is fewer than the same table gets while the betting is live", () => {
// The point of the change, stated as a comparison rather than a constant, so it
// still means something if the ordinary cap is ever retuned.
const live = build(laddered(12), { maxLayers: 4 }).length;
const runout = build(laddered(12), { maxLayers: 4, summarise: true }).length;
assert.ok(runout < live, `a run-out should be shorter than live betting (${runout} vs ${live})`);
});
test("still names the main pot, which is the one said out loud", () => {
const pots = laddered(12);
const rows = build(pots, { maxLayers: 4, summarise: true });
assert.equal(rows[0].label, pots[0].label);
assert.equal(rows[0].amount, Money.format(pots[0].amount));
});
test("and still accounts for every chip", () => {
// The same rule as the elision above, and the one a summary row must not break:
// a dealer counting the announcement against the chips in the middle must not
// find a shortfall the tool invented.
for (let players = 3; players <= 12; players++) {
const pots = laddered(players);
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
const drawn = build(pots, { maxLayers: 4, summarise: true })
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
assert.equal(drawn, total,
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
}
});
test("says how many side pots it stands for, and counts them properly at one", () => {
const many = build(laddered(12), { maxLayers: 4, summarise: true });
assert.equal(many[1].label, `${laddered(12).length - 1} side pots`);
// Three players ladder into two pots: a main and exactly one side.
const one = build(laddered(3), { maxLayers: 4, summarise: true });
assert.equal(one[1].label, "1 side pot", "a single side pot is not '1 side pots'");
});
test("and points at where the full list actually is", () => {
const rows = build(laddered(12), { maxLayers: 4, summarise: true });
assert.match(rows[1].who, /resolve/,
"the row that hides the layers has to say where they went");
assert.match(rows[1].title, /Side pot/,
"and a pointer still gets the full list without resolving anything");
});
});
+60
View File
@@ -122,3 +122,63 @@ describe("PotCalculator.resolveRound", () => {
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
});
});
// The pots come out of layers() in one order and with one relationship between them,
// and the action bar's wording now depends on both. If eligibility were not nested,
// "8 in, not Mike and Bob" would be a lie — there would be someone eligible for a side
// pot who was never eligible for the main one, and naming the complement would hide
// them. It is a property of how the layers are built rather than an accident of the
// examples above, so it is checked as one.
describe("pot eligibility is nested", () => {
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
test("every side pot's players are a strict subset of the pot below it", () => {
const random = rng(20260905);
let multiPotTables = 0;
let pairs = 0;
for (let table = 0; table < 20000; table++) {
const n = 2 + Math.floor(random() * 11);
const players = Array.from({ length: n }, (_, i) => p(
`p${i}`,
// A sixth of the table never puts anything in, which is a table mid-deal.
random() < 1 / 6 ? 0 : (1 + Math.floor(random() * 8)) * 25,
random() < 0.2));
const layers = PotCalculator.layers(players);
if (layers.length > 1) multiPotTables++;
for (let i = 1; i < layers.length; i++) {
const below = layers[i - 1].eligible;
const belowSet = new Set(below);
const here = layers[i].eligible;
pairs++;
assert.ok(here.every(id => belowSet.has(id)),
`table ${table}: ${layers[i].label} has a player ${layers[i - 1].label} does not\n` +
JSON.stringify(players));
assert.ok(here.length < below.length,
`table ${table}: ${layers[i].label} is no smaller than ${layers[i - 1].label} — ` +
`layers with the same eligibility are supposed to be merged into one pot\n` +
JSON.stringify(players));
}
}
// Guards the generator rather than the code: a shape that stopped producing side
// pots would make every assertion above vacuous and the test would still pass.
assert.ok(multiPotTables > 10000, `only ${multiPotTables} tables had a side pot`);
assert.ok(pairs > 20000, `only ${pairs} adjacent pairs to check`);
});
test("so the main pot is everyone still live, and it is the widest", () => {
const layers = PotCalculator.layers([p("a", 100), p("b", 40), p("c", 100), p("d", 25)]);
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c", "d"]);
layers.slice(1).forEach(layer =>
assert.ok(layer.eligible.length < layers[0].eligible.length));
});
});
+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);
});
});
+274
View File
@@ -0,0 +1,274 @@
// Reading the showdown: the picker that takes the cards in, and the pot maths that
// pays somebody on the strength of them.
//
// The evaluator is tested to death next door in handRank.test.js. What is tested here
// is the join — the two places where a correct ranking can still pay the wrong player:
// a side pot, where the question is asked again of a smaller field, and a card entered
// in two places, where the ranking is of a deck that does not exist.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { CardPicker, HandRank, HoldemState, PotCalculator, Money, makeTable } = require("./load");
describe("the picker, filling the board", () => {
const table = (board = ["", "", "", "", ""]) => {
const state = makeTable({ seatCount: 4, stack: 100 });
state.board = board;
return state;
};
// The sheet's own drawing is the DOM's business; the slot bookkeeping under it is
// not, and that is what these ask about.
const headless = () => { CardPicker.render = () => {}; CardPicker.close = () => {}; };
const onBoard = (state, cardIndex = 0, limit = 5) => {
headless();
CardPicker.state = state;
CardPicker.hooks = { onPick: () => {} };
CardPicker.target = "board";
CardPicker.boardLimit = limit;
CardPicker.cardIndex = cardIndex;
return CardPicker;
};
const onSeat = (state, seatIndex, cardIndex = 0) => {
headless();
CardPicker.state = state;
CardPicker.hooks = { onPick: () => {} };
CardPicker.target = "seat";
CardPicker.seatIndex = seatIndex;
CardPicker.cardIndex = cardIndex;
return CardPicker;
};
test("it only offers the slots that have actually been turned", () => {
const state = table();
assert.equal(onBoard(state, 0, 3).slotValues().length, 3, "the flop is three");
assert.equal(onBoard(state, 0, 4).slotValues().length, 4, "the turn makes four");
assert.equal(onBoard(state, 0, 5).slotValues().length, 5);
});
test("a card goes into the board, not into a seat", () => {
const state = table();
onBoard(state, 1, 3).assign("KD");
assert.deepEqual(state.board, ["", "KD", "", "", ""]);
assert.deepEqual(state.seats[0].cards, ["", ""]);
});
test("it walks on to the next empty slot, so a flop is three taps", () => {
const state = table();
const picker = onBoard(state, 0, 3);
picker.assign("KD");
assert.equal(picker.cardIndex, 1);
picker.assign("9C");
assert.equal(picker.cardIndex, 2);
picker.assign("4S");
assert.deepEqual(state.board.slice(0, 3), ["KD", "9C", "4S"]);
});
test("and it does not walk past the slots it was given", () => {
// Filling the turn must not run on to the river: that card is still in the deck.
const state = table(["KD", "9C", "4S", "", ""]);
const picker = onBoard(state, 3, 4);
picker.assign("AH");
assert.deepEqual(state.board, ["KD", "9C", "4S", "AH", ""]);
});
test("clearing a slot empties that one and nothing else", () => {
const state = table(["KD", "9C", "4S", "", ""]);
onBoard(state, 1, 3).clearSlot();
assert.deepEqual(state.board, ["KD", "", "4S", "", ""]);
});
});
describe("a card cannot be in two places", () => {
const table = () => {
const state = makeTable({ seatCount: 4, stack: 100 });
state.board = ["KD", "9C", "4S", "", ""];
state.seats[0].cards = ["AH", "AS"];
state.seats[1].cards = ["", ""];
return state;
};
const picker = (state, setup) => {
CardPicker.state = state;
CardPicker.hooks = { onPick: () => {} };
setup();
return CardPicker;
};
test("the board's cards are unavailable to a hand", () => {
// The half of this that did not exist before: the picker knew about every
// player's cards and nothing about the middle of the table, so the king of
// diamonds on the flop could be dealt to somebody as well.
const state = table();
const used = picker(state, () => {
CardPicker.target = "seat";
CardPicker.seatIndex = 1;
CardPicker.cardIndex = 0;
}).cardsInUse();
assert.equal(used.get("KD"), "the board");
assert.equal(used.get("9C"), "the board");
assert.ok(used.has("AH"), "and the other players' are still in there");
});
test("and a hand's cards are unavailable to the board", () => {
const state = table();
const used = picker(state, () => {
CardPicker.target = "board";
CardPicker.boardLimit = 5;
CardPicker.cardIndex = 3;
}).cardsInUse();
assert.ok(used.has("AH"), "the ace in seat 1's hand");
assert.ok(used.has("KD"), "and the rest of the board");
});
test("but the slot being edited is not competing with itself", () => {
const state = table();
const used = picker(state, () => {
CardPicker.target = "board";
CardPicker.boardLimit = 3;
CardPicker.cardIndex = 0;
}).cardsInUse();
assert.equal(used.has("KD"), false, "re-picking what is already there is not a duplicate");
assert.equal(used.has("9C"), true);
});
test("the same goes for a seat's own slot", () => {
const state = table();
const used = picker(state, () => {
CardPicker.target = "seat";
CardPicker.seatIndex = 0;
CardPicker.cardIndex = 0;
}).cardsInUse();
assert.equal(used.has("AH"), false);
assert.equal(used.has("AS"), true, "the player's other card still is");
});
});
describe("who wins a side pot", () => {
// The case a single ranking of the whole table gets wrong. The best hand takes the
// main pot and is then out of the question: the side pot goes to the best of who is
// left, which is a different answer, and it is the answer the wizard has to ask for
// again each round rather than reading off one list.
//
// Three players, and the short stack has the best hand of the three.
const board = ["KD", "9C", "4S", "AH", "2H"];
const hands = {
short: ["9H", "9S"], // trips — the best hand at the table
big: ["AS", "KC"], // two pair, aces and kings
other: ["AC", "3D"], // a pair of aces
};
const entries = () => PotCalculator.buildEntries([
{ id: "short", contribution: 20, folded: false },
{ id: "big", contribution: 100, folded: false },
{ id: "other", contribution: 100, folded: false },
]);
// What the wizard does: read the hands still in for *this* round.
const readRound = state => HandRank.showdown(board,
PotCalculator.getContenders(state).map(c => ({ id: c.id, cards: hands[c.id] })));
test("the short stack takes the main pot with the best hand", () => {
const round = readRound(entries());
assert.equal(round.complete, true);
assert.deepEqual(round.winners, ["short"]);
});
test("and the side pot goes to the best of the two who are left", () => {
const first = PotCalculator.resolveRound(entries(), ["short"]);
// 20 from each of three: the short stack is paid 60 and is out of it.
assert.equal(Money.round(first.perWinner[0].amount), 60);
const round = readRound(first.updatedEntries);
assert.deepEqual(round.winners, ["big"],
"not the trips, which are no longer eligible for this money");
assert.equal(round.players.length, 2, "and the short stack is not even offered");
const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
assert.equal(Money.round(second.perWinner[0].amount), 160);
});
test("every chip is accounted for across the two rounds", () => {
const first = PotCalculator.resolveRound(entries(), ["short"]);
const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
assert.equal(Money.round(first.payoutPool + second.payoutPool), 220);
assert.equal(PotCalculator.getContenders(second.updatedEntries).length, 0);
});
});
describe("what it refuses to answer", () => {
const board = ["KD", "9C", "4S", "AH", "2H"];
test("a folded player's cards are not in the running", () => {
// They are not a contender, so they are never handed in — which means a hand
// that would have won cannot take a pot its owner threw away.
const table = PotCalculator.buildEntries([
{ id: "live", contribution: 50, folded: false },
{ id: "mucked", contribution: 50, folded: true },
]);
const contenders = PotCalculator.getContenders(table);
assert.deepEqual(contenders.map(c => c.id), ["live"]);
const round = HandRank.showdown(board, contenders.map(c => ({ id: c.id, cards: ["7C", "5D"] })));
assert.deepEqual(round.winners, ["live"], "even holding nothing");
});
test("one hand not turned over and it hands the question back", () => {
const round = HandRank.showdown(board, [
{ id: "a", cards: ["9H", "9S"] },
{ id: "b", cards: ["", ""] },
]);
assert.equal(round.complete, false);
assert.deepEqual(round.winners, []);
assert.deepEqual(round.unknown, ["b"]);
});
test("a board still being dealt is not a showdown", () => {
const round = HandRank.showdown(["KD", "9C", "4S", "", ""], [
{ id: "a", cards: ["9H", "9S"] },
{ id: "b", cards: ["AS", "KC"] },
]);
assert.equal(round.complete, false);
assert.deepEqual(round.winners, []);
});
test("a card entered twice stops it rather than guessing", () => {
// The failure mode that matters: a mis-typed card produces a hand that ranks
// perfectly well and is not the one on the table. Refusing is the only honest
// answer, and the dealer picks the winner the way they always could.
const round = HandRank.showdown(board, [
{ id: "a", cards: ["9H", "9S"] },
{ id: "b", cards: ["KD", "QC"] }, // the king of diamonds is on the board
]);
assert.equal(round.duplicated, true);
assert.equal(round.complete, false);
assert.deepEqual(round.winners, []);
});
test("and two players holding the same card is caught as well", () => {
const round = HandRank.showdown(board, [
{ id: "a", cards: ["QH", "QS"] },
{ id: "b", cards: ["QH", "JC"] },
]);
assert.equal(round.duplicated, true);
assert.equal(round.complete, false);
});
});
describe("a table that never asked for any of this", () => {
test("has no board, tracks nothing, and reads no showdown", () => {
const state = HoldemState.createDefault();
assert.equal(state.trackBoard, false);
// Which is what the wizard checks before it reads anything at all — see
// readShowdown() in potWizard.js. With it off, an empty board would decline
// anyway, so the tool cannot start answering a question nobody asked.
const round = HandRank.showdown(state.board, [
{ id: "a", cards: ["9H", "9S"] },
{ id: "b", cards: ["AS", "KC"] },
]);
assert.equal(round.complete, false);
assert.deepEqual(round.winners, []);
});
});
+282
View File
@@ -0,0 +1,282 @@
// Motion, for the people who asked for less of it.
//
// The stylesheet had honoured `prefers-reduced-motion` for a while, and all three places
// it did were the wrong size of thing: a 2px press-down on a button, a checkbox tick,
// and the acting seat's pulse. What none of them covered was motion that moves something
// across the screen — and for anyone with a vestibular disorder that is the part that
// matters. A button dipping under a thumb is not what makes people ill.
//
// Four things were doing it. A seat editor that slides the full height of itself up from
// off-screen, every time a dealer taps a chair. A toast that slides in from below. And
// three `scrollIntoView({ behavior: "smooth" })` calls, which are the most provocative of
// the lot, because what moves is the whole viewport rather than one control.
//
// The scripts are checked here against a stand-in for matchMedia; the stylesheet is
// checked against its own text, the way the seat footprint rules already are.
const test = require("node:test");
const { describe, afterEach } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Render } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// ---- The scripts ------------------------------------------------------------
// Node has no matchMedia at all, so this installs one rather than replacing one. It is
// defined as a plain value here for the same reason `navigator` had to be defined with
// defineProperty in wakeLock.test.js: assigning to a global the runtime owns can be a
// silent no-op, and a test that thinks it installed a fake and didn't passes for the
// wrong reason.
const setReducedMotion = wanted => {
Object.defineProperty(globalThis, "matchMedia", {
value: query => ({
matches: wanted && /prefers-reduced-motion:\s*reduce/.test(query),
media: query,
}),
configurable: true,
writable: true,
});
};
const unsetMatchMedia = () => { delete globalThis.matchMedia; };
// An element that records what it was asked to do, and nothing else.
const fakeElement = () => {
const calls = [];
return { calls, scrollIntoView(options) { calls.push(options); } };
};
afterEach(unsetMatchMedia);
describe("what the tool reads off the setting", () => {
test("a browser asking for less motion is heard", () => {
setReducedMotion(true);
assert.equal(Render.prefersReducedMotion(), true);
});
test("and one that isn't, isn't", () => {
setReducedMotion(false);
assert.equal(Render.prefersReducedMotion(), false);
});
test("a browser too old to have the question is not assumed to be answering it", () => {
// No matchMedia at all. The safe reading is "no preference stated", which leaves
// the tool behaving exactly as it did — not "reduce", which would quietly take
// the animation away from everybody on an old browser.
unsetMatchMedia();
assert.equal(Render.prefersReducedMotion(), false);
});
});
describe("bringing something into view", () => {
test("scrolls smoothly when nobody has asked otherwise", () => {
setReducedMotion(false);
const el = fakeElement();
Render.bringIntoView(el, { block: "center" });
assert.deepEqual(el.calls, [{ block: "center", behavior: "smooth" }]);
});
test("and jumps when they have", () => {
setReducedMotion(true);
const el = fakeElement();
Render.bringIntoView(el, { block: "center" });
assert.deepEqual(el.calls, [{ block: "center", behavior: "auto" }]);
});
test("the caller still chooses where it lands, only not how it gets there", () => {
// Each of the three callers wants a different alignment — the raise field
// centred, the seat sheet nearest, the sidebar section at the top — and the
// helper must not flatten that into one answer.
setReducedMotion(true);
for (const block of ["center", "nearest", "start"]) {
const el = fakeElement();
Render.bringIntoView(el, { block });
assert.equal(el.calls[0].block, block);
}
});
test("a caller that never chose gets a plain scroll rather than an exception", () => {
setReducedMotion(false);
const el = fakeElement();
Render.bringIntoView(el);
assert.equal(el.calls[0].behavior, "smooth");
});
test("nothing to scroll is not an error", () => {
// main.js reaches for #seatEditorPanel by id and passes whatever it finds.
setReducedMotion(true);
assert.doesNotThrow(() => Render.bringIntoView(null));
assert.doesNotThrow(() => Render.bringIntoView(undefined));
assert.doesNotThrow(() => Render.bringIntoView({}));
});
test("the setting is re-read every time, not remembered from page load", () => {
// A dealer who turns reduced motion on mid-session should be taken seriously
// without reloading the table — and the table is a thing they cannot afford to
// reload lightly.
setReducedMotion(false);
const el = fakeElement();
Render.bringIntoView(el, { block: "start" });
setReducedMotion(true);
Render.bringIntoView(el, { block: "start" });
assert.deepEqual(el.calls.map(c => c.behavior), ["smooth", "auto"]);
});
});
// ---- The stylesheet ---------------------------------------------------------
// Everything inside the @media block whose condition matches, concatenated — there is
// more than one reduced-motion block in the file and they govern different things.
const reducedMotionBlocks = (extraCondition = null) => {
const found = [];
const needle = "@media ";
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
const open = CSS.indexOf("{", at);
const condition = CSS.slice(at + needle.length, open).trim();
if (!/prefers-reduced-motion:\s*reduce/.test(condition)) continue;
if (extraCondition && !condition.includes(extraCondition)) continue;
let depth = 0;
let i = open;
for (; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) break;
}
found.push(CSS.slice(open + 1, i));
}
return found;
};
describe("the stylesheet stops the things that actually travel", () => {
test("there is more than one reduced-motion block, and one of them is for the sheet", () => {
assert.ok(reducedMotionBlocks().length >= 2,
"the press-down block alone was never the whole job");
assert.equal(reducedMotionBlocks("max-width").length, 1,
"the seat sheet's override has to carry the same width condition as the rule it beats");
});
test("the toast keeps its fade and loses its travel", () => {
const all = reducedMotionBlocks().join("\n");
const toast = all.slice(all.indexOf(".toast"));
assert.match(toast, /transform:\s*none/,
"a toast sliding up from the bottom edge is exactly the motion being asked about");
// The guidance is not "delete the animation": something that appears already in
// place reads as a rendering glitch rather than as news. Opacity survives.
assert.match(toast, /transition:\s*opacity/,
"the fade has to stay, or the toast stops reading as something that just happened");
});
test("the seat sheet stops sliding, and stops being painted after it has gone", () => {
const [sheet] = reducedMotionBlocks("max-width");
assert.ok(sheet, "the sheet override should exist");
assert.doesNotMatch(sheet, /transition:[^;]*transform/,
"the transform transition is the slide, and it is the largest movement here");
// The delay is what kept the slide-out on screen for its own quarter second.
// With the slide gone it would leave a sheet that has already jumped away still
// painted, which is a flicker rather than a courtesy.
const flips = sheet.match(/transition:\s*visibility\s+0s[^;]*/g) || [];
assert.equal(flips.length, 2,
"both directions need saying: the rule that puts the sheet away and the one that opens it");
assert.ok(flips.every(rule => /^transition:\s*visibility\s+0s(\s+linear\s+0s)?\s*$/.test(rule.trim())),
`visibility has to flip at once in both directions once there is no slide to cover: ${flips}`);
assert.doesNotMatch(sheet, /linear\s+0\.\d+s/,
"no leftover delay measured against an animation that no longer runs");
});
});
// ---- And the same rule, applied to whatever the file grows next --------------
//
// The three checks above name the things that were wrong. This one is the general
// version, and it is the one that will still be doing work in a year: it scans the
// stylesheet for every `:hover` and `:active` transform there is and insists each one
// has an off switch.
//
// Written because the existing block's membership had gone wrong in a way no
// enumerated test would have caught. It held nine `:active` selectors and looked
// deliberate, but it had been written to stop a *stuck press state* when a tap turns
// into a scroll — reaching for the reduced-motion query as the nearest proxy — and
// solving one problem with another's tool is how it came to cover `.dealerChip` and
// `.raiseQuickBtn` while missing `.actionBtn`, the class on every button in the action
// bar, and every `:hover` in the file.
describe("every hover and press in the file has an off switch", () => {
// Selectors that move something under the pointer.
//
// Parsed as blocks rather than scanned line by line, and that distinction is not
// pedantry — the line-by-line version was written first and a fault injection walked
// straight past it. A rule written on one line puts its selector and its declaration
// in the same string, so neither "this line opens a rule" nor "this line is a
// transform" matched, and the scan reported a clean file while the lift it was meant
// to find sat in plain sight.
//
// The pattern matches innermost blocks only: a selector cannot contain braces, so an
// @media wrapper's body never matches and its contents are reached individually.
const pointerTransforms = () => {
const withoutComments = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
const found = [];
for (const [, selector, body] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const name = selector.trim();
if (name.startsWith("@")) continue;
if (!name.includes(":hover") && !name.includes(":active")) continue;
const moves = body.match(/(^|[;{\s])transform\s*:\s*([^;]+)/);
if (!moves || /^none\b/.test(moves[2].trim())) continue;
// Split the group. A comma-separated rule is nine separate promises, and
// checking the joined string against the reduce block is a test that can only
// pass if both lists happen to be written in the same order — which is to say
// a test that passes by luck. It also hid three selectors outright: the
// line-scanning version kept only the last line of a multi-line group, so
// .toastAction, .toastClose and .potWizardChoice were never looked at.
for (const one of name.split(",")) {
const selector = one.trim().replace(/\s+/g, " ");
if (!selector.includes(":hover") && !selector.includes(":active")) continue;
found.push({ selector, declaration: moves[2].trim() });
}
}
return found;
};
const reduceText = () => reducedMotionBlocks().join("\n");
test("there are some to check, or this test is measuring nothing", () => {
// The guard the accent check taught us to write: a scan that finds nothing
// passes just as green as a scan that finds everything covered.
assert.ok(pointerTransforms().length >= 4,
"the file should still have hover/press transforms in it");
});
test("and every one of them is turned off under reduced motion", () => {
const text = reduceText();
// A selector counts as covered when it appears in a reduce block, comma-separated
// list and all. Compared with whitespace flattened, because the reduce block puts
// each selector on its own line and the rule it answers may not.
const flat = text.replace(/\s+/g, " ");
const missing = pointerTransforms()
.filter(rule => !flat.includes(rule.selector))
.map(rule => `${rule.selector} { transform: ${rule.declaration} }`);
assert.deepEqual(missing, [],
`these move under the pointer with no reduced-motion escape:\n ${missing.join("\n ")}`);
});
test("including the one a dealer touches most", () => {
// Named on its own as well as caught by the scan, because this is the specific
// omission that prompted the scan: Fold, Call, Raise and Post blinds & deal all
// carry .actionBtn, and it was the one control missing from a list of nine.
assert.match(reduceText(), /\.actionBtn:active/,
"the action bar's own buttons are the most-pressed thing in the tool");
});
test("a looping animation only runs where motion is welcome", () => {
// The acting seat breathes. An infinite animation is the one kind that cannot be
// waited out, so it has to be inside a no-preference block rather than merely
// overridden in a reduce one.
const noPreference = CSS.indexOf("@media (prefers-reduced-motion: no-preference)");
assert.ok(noPreference > 0, "the pulse should sit behind a no-preference query");
const infinite = [...CSS.matchAll(/animation:[^;]*infinite/g)].map(m => m.index);
assert.ok(infinite.length > 0, "there should still be a looping animation to check");
assert.ok(infinite.every(at => at > noPreference),
"every looping animation has to sit inside the no-preference block");
});
});
+287
View File
@@ -0,0 +1,287 @@
// Reads every piece of text on the dealer's page and asks whether it can actually be
// read, at the size and weight it is actually drawn.
//
// node tests/holdem/runContrastAudit.js [baseUrl]
//
// Needs Playwright and a running site, like runUiAudit.js, and is deliberately kept
// out of the unit suite for the same reason.
//
// Why this exists next to the contrast check already in uiAudit.js: that one measures
// WCAG 2 contrast ratio and the page passes it. WCAG 2's ratio is a luminance gap, and
// a luminance gap overstates readability once one colour is near black — which is
// every reading taken on a page whose background is #141414. APCA asks the question
// the ratio can't. Both numbers are reported, because "passes AA, fails APCA" is the
// interesting row and hiding either number would hide it.
//
// How the background is obtained matters more than the arithmetic. The first version
// walked the DOM compositing `background-color` up the ancestors, and reported the gold
// dealer button as black text on a black background at Lc 0.4 — a fault that does not
// exist on an element anyone can see is legible, because a gradient was doing the
// painting and `background-color` knew nothing about it. So: paint the page with its
// text made transparent, screenshot it, and read the background out of the pixels. The
// text's own client rects come from a Range, not the element box, so a label inside a
// wide row is sampled where the glyphs are rather than wherever the row happens to end.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { lc, requiredLc } = require("./apca");
const { decode, pixelAt } = require("./png");
const BASE = process.argv[2] || "http://localhost:5199";
const VIEWPORTS = [
[390, 844, "iPhone 14"],
[768, 1024, "iPad"],
[1280, 900, "desktop"],
];
// WCAG 2 relative luminance — the piecewise curve, which is not the one APCA uses.
function wcagLuminance([r, g, b]) {
const f = c => {
c /= 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
function wcagRatio(fg, bg) {
const a = wcagLuminance(fg), b = wcagLuminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
function seedTable() {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
["Bob", 250], ["Dee", 600]].forEach(([name, cash], i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = cash;
state.seats[i].stack = cash;
});
state.smallBlind = 5;
state.bigBlind = 10;
state.handNumber = 5;
state.handHistory.push({
handNumber: 4, timestamp: Date.now(), pot: 120,
payouts: [{ id: state.seats[0].id, label: "Christopher", amount: 120 }],
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
});
HoldemState.save(state);
}
// Every text node that is actually painted, with the rect its glyphs occupy.
function collectText() {
const out = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent.trim();
if (!text) continue;
const el = node.parentElement;
if (!el || el.closest("[hidden]")) continue;
const cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none") continue;
// Anything faded is faded onto what is behind it, and the screenshot already
// shows that — but fully transparent text is not text.
let opacity = 1;
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
opacity *= parseFloat(getComputedStyle(n).opacity);
}
if (opacity < 0.05) continue;
const range = document.createRange();
range.selectNodeContents(node);
const rects = [...range.getClientRects()].filter(r => r.width > 1 && r.height > 1);
if (!rects.length) continue;
const r = rects[0];
if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) continue;
const m = cs.color.match(/[\d.]+/g);
if (!m) continue;
out.push({
text: text.slice(0, 40),
color: [+m[0], +m[1], +m[2]],
alpha: m[3] !== undefined ? +m[3] : 1,
opacity,
px: parseFloat(cs.fontSize),
weight: +cs.fontWeight || 400,
cls: (el.className && el.className.toString().trim().split(/\s+/)[0]) || el.tagName.toLowerCase(),
tag: el.tagName.toLowerCase(),
disabled: !!el.closest(":disabled, [disabled], [aria-disabled='true']"),
// Sample points along the glyph run, avoiding the very edges where a
// border or the next box over would be sampled instead.
points: [0.25, 0.5, 0.75].map(t => [r.left + r.width * t, r.top + r.height / 2]),
});
}
return out;
}
const HIDE_TEXT = `*, *::before, *::after {
color: transparent !important;
text-shadow: none !important;
-webkit-text-stroke-color: transparent !important;
caret-color: transparent !important;
}`;
async function main() {
let chromium;
try {
({ chromium } = require("playwright"));
} catch {
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
process.exit(2);
}
const shotDir = fs.mkdtempSync(path.join(os.tmpdir(), "holdem-contrast-"));
const browser = await chromium.launch();
const context = await browser.newContext({ deviceScaleFactor: 1 });
const page = await context.newPage();
const rows = [];
for (const [width, height, label] of VIEWPORTS) {
await page.setViewportSize({ width, height });
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(() => {
const deal = [...document.querySelectorAll("#actionBar button")]
.find(b => /post blinds/i.test(b.textContent));
if (deal) deal.click();
});
await page.waitForTimeout(250);
// Open every panel, or the sidebar's hint text — the smallest, faintest text on
// the page, and so the most likely to be the thing worth finding — is never
// measured at all.
await page.evaluate(() => {
document.querySelectorAll("details").forEach(d => { d.open = true; });
});
await page.waitForTimeout(200);
const items = await page.evaluate(collectText);
const shot = path.join(shotDir, `${width}x${height}.png`);
await page.addStyleTag({ content: HIDE_TEXT });
await page.waitForTimeout(120);
await page.screenshot({ path: shot });
// Undo, so the next viewport starts from a page that still has its text.
await page.evaluate(() => {
const tags = [...document.querySelectorAll("style")];
const injected = tags[tags.length - 1];
if (injected && injected.textContent.includes("caret-color: transparent")) injected.remove();
});
const image = decode(fs.readFileSync(shot));
for (const item of items) {
// Most common sampled colour along the run: a median would invent a colour
// that is on no pixel, and the mode lands on the flat background wherever
// there is one.
const tally = new Map();
for (const [x, y] of item.points) {
const key = pixelAt(image, x, y).join(",");
tally.set(key, (tally.get(key) || 0) + 1);
}
const bg = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
.split(",").map(Number);
// The text's painted colour: its own alpha, and any inherited opacity, both
// land it on the background behind it.
const a = item.alpha * item.opacity;
const fg = item.color.map((c, i) => c * a + bg[i] * (1 - a));
const measured = Math.abs(lc(fg, bg));
const needed = requiredLc(item.px, item.weight);
rows.push({
viewport: label,
...item,
bg, fg: fg.map(Math.round),
lc: +measured.toFixed(1),
need: +needed.toFixed(0),
short: +(needed - measured).toFixed(1),
wcag: +wcagRatio(fg, bg).toFixed(2),
});
}
}
await browser.close();
fs.rmSync(shotDir, { recursive: true, force: true });
// One row per distinct piece of text, worst viewport kept.
const byKey = new Map();
for (const row of rows) {
const key = `${row.cls}|${row.px}|${row.weight}|${row.text}`;
const prev = byKey.get(key);
if (!prev || row.short > prev.short) byKey.set(key, row);
}
const all = [...byKey.values()].sort((a, b) => b.short - a.short);
const live = all.filter(r => !r.disabled);
const failing = live.filter(r => r.short > 0);
const passesAaFailsApca = failing.filter(r => r.wcag >= 4.5);
// Two numbers, because they answer different questions and only one of them is a
// target anyone can hit here.
//
// The strict count is every piece of text below the published level for the size and
// weight it is drawn at. APCA asks Lc 90 of anything at 12px/400 or below, and this
// page is full of 10px and 11px labels that live inside a 58px seat puck. Reaching
// Lc 90 there means white on black for micro-labels and a flat, shoutier page; the
// honest way to clear it is bigger type, and bigger type is a layout change that
// belongs to its own round. So the strict count is reported and not chased.
//
// The floors are what this page holds itself to: nothing below Lc 60, which is the
// published minimum for content text that is not a column, and Lc 75 for a column of
// body text. Those are checkable and they are the numbers that moved.
const FLOOR = 60, BODY_FLOOR = 75;
const belowFloor = live.filter(r => r.lc < FLOOR);
const belowBody = live.filter(r => r.lc < BODY_FLOOR);
const aaFails = live.filter(r => r.wcag < 4.5);
const worst = live.reduce((min, r) => Math.min(min, r.lc), Infinity);
console.log(`${rows.length} readings, ${all.length} distinct pieces of text`);
console.log(`worst contrast anywhere on the page: Lc ${worst.toFixed(1)}`);
console.log(`below the Lc ${FLOOR} floor: ${belowFloor.length}`);
console.log(`below Lc ${BODY_FLOOR}: ${belowBody.length}`);
console.log(`still failing WCAG AA 4.5:1: ${aaFails.length}`);
console.log(`below the strict APCA level for their size/weight: ${failing.length}`
+ ` (${passesAaFailsApca.length} of them pass AA)\n`);
if (belowFloor.length) {
console.log(`=== below the Lc ${FLOOR} floor ===`);
belowFloor.sort((a, b) => a.lc - b.lc).forEach(r =>
console.log(` Lc ${String(r.lc).padStart(5)} ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
console.log("");
}
if (aaFails.length) {
console.log("=== still failing WCAG AA ===");
aaFails.sort((a, b) => a.wcag - b.wcag).forEach(r =>
console.log(` ${String(r.wcag).padStart(5)}:1 ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
console.log("");
}
const show = r => ` Lc ${String(r.lc).padStart(5)} (needs ${String(r.need).padStart(2)},`
+ ` short ${String(r.short).padStart(5)}) WCAG ${String(r.wcag).padStart(5)}:1`
+ ` ${r.px}px/${r.weight} ${r.cls.padEnd(22)} rgb(${r.fg.join(",")}) on rgb(${r.bg.join(",")})`
+ ` "${r.text}"`;
console.log("=== worst 30 ===");
failing.slice(0, 30).forEach(r => console.log(show(r)));
const byClass = new Map();
failing.forEach(r => {
const cur = byClass.get(r.cls);
if (!cur || r.short > cur.short) byClass.set(r.cls, r);
});
console.log(`\n=== ${byClass.size} distinct classes below level ===`);
[...byClass.values()].sort((a, b) => b.short - a.short)
.forEach(r => console.log(` ${String(r.short).padStart(5)} short ${r.px}px/${r.weight} ${r.cls}`));
}
main().catch(err => { console.error(err); process.exit(1); });
+209 -49
View File
@@ -34,26 +34,64 @@ const OVERLAYS = [
];
// A table part-way through a real night: long names, uneven stacks, a hand log.
function seedTable() {
//
// Two of them. The sweep ran on one table shape for its whole life — five players at a
// nine-handed table — and how much room the felt has depends on how many people are
// sitting down more than on anything a viewport can tell you. A full table is where the
// seats crowd, and it was never once looked at: measured when it finally was, seventeen
// of fifty-four tables had one player's name or stack painted under another.
const PACKED = [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]];
const TABLES = [
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
["Bob", 250], ["Dee", 600]], false],
["packed", 12, PACKED],
// The same twelve, every one of them all-in for a different amount. This is the
// shape that makes a pot layer per player, and the sweep had never reached it:
// every table here was driven with Call and Check, so the side-pot breakdown — the
// one thing on the bar whose height depends on the table — was empty at all 294
// audits. Measured when it finally was: the bar took 411px of a 640px phone and
// left 96px of felt for twelve seats.
["all in", 12, PACKED, false, 12],
// The half-full table again, with the board turned on and five cards on it. The
// board is furniture in the middle of the felt that nothing else was ever measured
// against — and where the middle cannot hold it, it moves to a strip above the
// table and takes the felt's height with it, which is the seats' problem too.
//
// Deliberately the same five players in the same nine chairs as the shape above, so
// the only thing that differs between them is the board. A seventh player would have
// told us that a seven-handed table crowds, which is true, already known, and not
// what this row is here to find out.
["board", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
["Bob", 250], ["Dee", 600]], true],
];
function seedTable([seats, players, board]) {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]]
.forEach(([name, cash], i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = cash;
state.seats[i].stack = cash;
});
HoldemState.resizeSeats(state, seats);
players.forEach(([name, cash], i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = cash;
state.seats[i].stack = cash;
});
state.smallBlind = 5;
state.bigBlind = 10;
for (let hand = 1; hand <= 4; hand++) {
state.handHistory.push({
handNumber: hand, timestamp: Date.now(), pot: 120,
payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }],
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
payouts: [{ id: state.seats[hand % players.length].id, label: "P", amount: 120 }],
ledger: state.seats.slice(0, players.length)
.map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
});
}
state.handNumber = 5;
if (board) {
state.trackBoard = true;
state.board = ["KD", "9C", "4S", "AH", "2H"];
}
HoldemState.save(state);
}
@@ -68,57 +106,179 @@ 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) {
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}`));
for (const [shape, seats, players, board, shoves] of TABLES) {
const label = `${rawLabel} · ${shape} · ${hand}`;
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable, [seats, players, board]);
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.click("#nextHandBtn");
await page.waitForTimeout(300);
// Deal it, and get chips in front of a few players. The sweep used to
// audit a table that had never had a bet on it, so the bets layer was
// empty at every viewport it visited and nothing it drew was ever looked
// at — which is a third of what is on the felt during a hand.
await page.evaluate(() => {
const deal = [...document.querySelectorAll("#actionBar button")]
.find(b => /post blinds/i.test(b.textContent));
if (deal) deal.click();
});
await page.waitForTimeout(220);
// Everyone's whole stack in, which is what stacks up the side pots.
// There is no one-tap shove: a player who can't cover the bet gets
// "Call all-in", and anyone else has to open the raise pad, take its
// All-in preset and confirm.
for (let i = 0; i < (shoves || 0); i++) {
const step = await page.evaluate(() => {
const find = re => [...document.querySelectorAll("#actionBar button")]
.find(b => !b.disabled && re.test(b.textContent.trim()));
const short = find(/^Call all-in/);
if (short) { short.click(); return "done"; }
const open = find(/^Raise|^Bet/);
if (!open) return null;
open.click();
return "pad";
});
if (!step) break;
await page.waitForTimeout(120);
if (step === "pad") {
await page.evaluate(() => {
const find = re => [...document.querySelectorAll("#actionBar button")]
.find(b => !b.disabled && re.test(b.textContent.trim()));
const max = find(/^All-in/);
if (max) max.click();
const go = find(/^Raise to|^Bet/);
if (go) go.click();
});
await page.waitForTimeout(120);
}
}
for (let i = 0; i < (shoves ? 0 : 3); i++) {
const acted = await page.evaluate(() => {
const btn = [...document.querySelectorAll("#actionBar button")]
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
if (!btn) return false;
btn.click();
return true;
});
if (!acted) break;
await page.waitForTimeout(120);
}
if (board) {
await page.evaluate(() => {
const s = HoldemState.load();
s.street = "river";
s.board = ["KD", "9C", "4S", "AH", "2H"];
s.seats.filter(p => p.inHand).forEach((p, i) => {
p.cards = [["AS", "KC"], ["AC", "3D"], ["9H", "9S"],
["7C", "5D"], ["JD", "TS"]][i] || ["", ""];
});
HoldemState.save(s);
});
await page.reload();
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);
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 });
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}`));
// The raise pad, which is part of the bar rather than an overlay and so
// was never once measured by any of this. It is the tallest thing the
// bar ever holds and the only place in the tool a dealer types an
// amount, and it had a 36px-tall field on every phone in the list: the
// `hover: none` rule that sizes fields for a thumb names the settings
// panels and the seat form, and the one field on the thumb surface was
// not in the list. Nothing here could see that, because nothing here
// ever opened it.
if (await page.evaluate(() => {
const btn = [...document.querySelectorAll("#actionBar button")]
.find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled);
if (!btn) return false;
btn.click();
return true;
})) {
await page.waitForTimeout(200);
checks++;
const pad = await page.evaluate(() => LayoutAudit.runAll({
occlusionSelector: "#actionBar button, #actionBar input",
}));
if (!pad.clean) {
failures.push({
where: `raise pad · ${label} ${width}x${height}`,
offenders: pad.failed.flatMap(f => f.offenders),
});
}
await page.evaluate(() => {
const cancel = [...document.querySelectorAll("#actionBar button")]
.find(b => /^Cancel/.test(b.textContent.trim()));
if (cancel) cancel.click();
});
await page.waitForTimeout(150);
}
// The overlays don't change with the table's shape, so they are only
// opened over the half-full one — twice the viewports is enough time
// spent already.
if (shape !== TABLES[0][0]) continue;
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, 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 [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");
+95
View File
@@ -0,0 +1,95 @@
// What happens when the browser refuses to save.
//
// The table keeps working — that was always true and is still the right behaviour.
// What was missing is anybody being told, which is the same shape of fault the bank
// check exists for: silent until it's too late to act on.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState, makeTable } = require("./load");
function withStorage(behaviour) {
const calls = [];
globalThis.localStorage = {
setItem(key, value) {
calls.push({ key, value });
if (behaviour === "throw") {
const error = new Error("QuotaExceededError");
error.name = "QuotaExceededError";
throw error;
}
},
getItem: () => null,
removeItem() {},
};
return calls;
}
test.beforeEach(() => {
HoldemState.saveFailed = false;
HoldemState.onSaveFailed = null;
});
test.after(() => {
delete globalThis.localStorage;
HoldemState.saveFailed = false;
HoldemState.onSaveFailed = null;
});
test("a save that works says so and leaves no mark", () => {
withStorage("ok");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(HoldemState.save(state), true);
assert.equal(HoldemState.saveFailed, false);
});
test("a save that fails says so rather than throwing", () => {
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(HoldemState.save(state), false);
assert.equal(HoldemState.saveFailed, true);
});
test("the table is told once, not on every action", () => {
// A dealer taps the bar thirty times a hand and doesn't need thirty warnings.
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
let told = 0;
HoldemState.onSaveFailed = () => { told++; };
for (let i = 0; i < 20; i++) HoldemState.save(state);
assert.equal(told, 1);
});
test("the failure is handed the error, so the reason can be reported later", () => {
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
let seen = null;
HoldemState.onSaveFailed = error => { seen = error; };
HoldemState.save(state);
assert.equal(seen && seen.name, "QuotaExceededError");
});
test("a failing save still doesn't stop the table", () => {
// The chips have already moved in memory; refusing to continue would be worse
// than not persisting.
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].stack = 175;
assert.doesNotThrow(() => HoldemState.save(state));
assert.equal(state.seats[0].stack, 175);
});
test("no listener is not an error", () => {
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
HoldemState.onSaveFailed = null;
assert.doesNotThrow(() => HoldemState.save(state));
assert.equal(HoldemState.saveFailed, true);
});
+226
View File
@@ -0,0 +1,226 @@
// Whether two seats can end up wanting the same pixels.
//
// The felt used to decide this from one number: the straight-line distance between the
// closest two *neighbouring* seats, judged against a fraction of a puck's width. Both
// halves of that were wrong, and the running page showed it. Measured across nine
// viewports and six table sizes:
//
// seventeen of fifty-four tables had one seat's name or stack painted under another
// ten of them had the collision between two seats that are not neighbours
//
// The second number is the interesting one. On a phone held sideways the felt is 528px
// wide and 87 tall, the ring is very nearly a straight line, and the seat that lands on
// top of you is the one across the table — seats 1 and 7, overlapping by 68px, while
// every neighbouring pair was fine and the check said the table was clean.
//
// The first is what a distance test can't fix even looking at every pair: distance has
// one number in it, and a puck is half again as wide as it is tall. 84px between centres
// is generous side by side and nowhere near enough on the diagonal.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Render } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
describe("two boxes on a ring", () => {
// A phone held sideways, once the action bar has taken its share: a felt far wider
// than it is tall, on which the ring is very nearly a straight line.
const felt = { width: 400, height: 70 };
const flat = { rx: 44, ry: 42 };
test("a pair that is not neighbours still counts", () => {
// Eight seats, a 48x60 puck. Every neighbouring pair is clear. Seats 1 and 5 are
// on top of each other, and so are 2 and 4, and 6 and 8 — the seat that lands on
// you here is the one across the table, because the rim doubles back at the ends
// of a shape this flat and a long walk apart is no distance at all.
assert.equal(Render.seatsCollide(8, felt, flat, 48, 60), true);
const neighbours = (count, w, h) => {
const points = Render.ringPositions(count, flat.rx, flat.ry, felt.width / felt.height)
.map(s => ({ x: (s.left / 100) * felt.width, y: (s.top / 100) * felt.height }));
return points.some((point, i) => {
const next = points[(i + 1) % points.length];
return Math.abs(point.x - next.x) < w && Math.abs(point.y - next.y) < h;
});
};
assert.equal(neighbours(8, 48 + Render.SEAT_CLEARANCE_PX, 60 + Render.SEAT_CLEARANCE_PX), false,
"no neighbouring pair overlaps, which is exactly why this had to look at all of them");
});
test("and the answer is the box, not a radius", () => {
// Two seats 60px apart across and 35px down, with a 64x40 puck. The distance
// between them is 69px, so the test this replaces — "are these two further apart
// than a puck is wide?" — called it clear. The boxes overlap by 4px by 5px.
const boxes = { width: 200, height: 100 };
const twoSeats = (dx, dy) => {
const original = Render.ringPositions;
Render.ringPositions = () => [
{ left: 50 - (dx / 2 / boxes.width) * 100, top: 50 - (dy / 2 / boxes.height) * 100 },
{ left: 50 + (dx / 2 / boxes.width) * 100, top: 50 + (dy / 2 / boxes.height) * 100 },
];
try {
return Render.seatsCollide(2, boxes, flat, 64, 40, 0);
} finally {
Render.ringPositions = original;
}
};
assert.ok(Math.hypot(60, 35) > 64, "and a distance test would have passed it");
assert.equal(twoSeats(60, 35), true, "60 across and 35 down, with a 64x40 puck");
assert.equal(twoSeats(60, 45), false, "far enough apart vertically");
assert.equal(twoSeats(66, 10), false, "far enough apart horizontally");
});
test("with daylight, so rounded corners don't read as touching", () => {
assert.ok(Render.SEAT_CLEARANCE_PX > 0);
});
test("a table nobody is sitting at can't collide with itself", () => {
assert.equal(Render.seatsCollide(1, felt, flat, 96, 60), false);
assert.equal(Render.seatsCollide(9, null, flat, 96, 60), false);
});
});
describe("the widths the felt may choose from", () => {
test("run widest first, because the widest that fits is the one wanted", () => {
const widths = Render.seatWidthCandidates();
widths.slice(1).forEach((w, i) => assert.ok(w < widths[i], `${widths[i]} then ${w}`));
});
test("and span the whole declared range", () => {
const widths = Render.seatWidthCandidates();
assert.equal(widths[0], Render.SEAT_WIDTH_MAX);
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
});
test("the floor is still a name, not a smudge", () => {
// At 44px the tight layout shows about five characters and an ellipsis. Below
// that a puck stops telling the dealer who is sitting there, which is the one
// thing on the felt it exists to say.
assert.ok(Render.SEAT_WIDTH_MIN >= 44);
assert.ok(Render.SEAT_TIGHT_BELOW_PX > Render.SEAT_WIDTH_MIN);
assert.ok(Render.SEAT_TIGHT_BELOW_PX < Render.SEAT_WIDTH_MAX);
});
test("and there are few enough of them to measure after every tap", () => {
// Each one costs a forced layout. Ten is the budget; the cache carries the rest.
assert.ok(Render.seatWidthCandidates().length <= 12);
});
});
describe("what the stylesheet has to keep holding up", () => {
test("the puck's width comes from the property, not from a breakpoint", () => {
// Three media queries used to declare it — 78px, 64px and 62px — three guesses
// at a number that depends on how many people are sitting down, which no media
// query can see. The same 390px phone has room for 90px at four seats and 61 at
// nine.
const declarations = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
const seatRules = [...declarations.matchAll(/(^|[,}])\s*(\.seat)\s*\{([^}]*)\}/g)]
.map(m => m[3]);
const hardWidths = seatRules
.flatMap(body => body.match(/(?<![\w-])width:\s*[^;]+/g) || [])
.filter(decl => !decl.includes("var(--seatPuckW"));
assert.deepEqual(hardWidths, []);
});
test("the puck measures what it says it measures", () => {
// As content-box a 64px puck came out 74 across once its padding and border were
// counted, and the search compares that number against distances on the felt.
const rule = CSS.match(/\n\.seat \{[^}]*\}/);
assert.ok(rule, ".seat rule not found");
assert.match(rule[0], /box-sizing:\s*border-box/);
});
test("and a blind badge never reaches sideways off its own puck", () => {
// This is what makes the footprint the puck and nothing more. The badge's
// remaining overhang is vertical, and it points away from the table at both ends
// — `top` on the upper arc, `bottom` on the lower — so two seats facing each
// other across the felt wear their badges back to back.
//
// It was not always so: at `left: -6px` the small blind hung off the puck's left
// corner, and on an iPhone Pro Max at six seats — pucks not touching at all —
// "SB" was painted across the next player's $1,137.
const badges = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)];
assert.equal(badges.length, 3, "expected all three blind badges");
badges.forEach(([, which, body]) => {
const left = body.match(/(?<![\w-])left:\s*(-?[\d.]+)px/);
assert.ok(left, `.${which}Badge should place itself horizontally`);
assert.ok(Number(left[1]) >= 0,
`.${which}Badge sits at ${left[1]}px, off the left of its own puck`);
});
});
test("and all three sit in the same place, so there is one spot to look at", () => {
// Two badges in two positions means the dealer's eye has to find which of them
// this seat is using before it can read which blind it is, forty times a night.
// A seat only ever wears one: the blinds are different players by construction,
// and the straddle chain starts left of the big blind and stops short of the
// small one.
const lefts = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)]
.map(([, , body]) => (body.match(/(?<![\w-])left:\s*(-?[\d.]+px)/) || [])[1]);
assert.equal(new Set(lefts).size, 1, `badges sit at ${lefts.join(", ")}`);
});
});
describe("the blind badge counts", () => {
// It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px
// off the puck and always points away from the table — `top: -9px` on the upper arc,
// `bottom: -9px` on the lower — so two seats facing each other across the felt wear
// them back to back and it costs nothing. That much is true.
//
// What was also claimed is that two seats on the *same* arc "wear them at the same
// height, offset by exactly as much as the pucks are". True of a straight rail,
// false of a ring: neighbours on a curve sit at different heights, so the badge
// pointing away from the table on one of them points at the corner of the other.
// Measured on the running page, three tables in the sweep had a player's stack
// painted under the next player's SB or BB — and the badge outranks everything else
// on the felt, so what the dealer reads in that spot is the wrong number entirely.
test("a seat needs room for the badge it might be wearing", () => {
const puck = { width: 64, height: 40 };
assert.deepEqual(Render.seatFootprint(puck),
{ width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX });
});
test("sideways it costs nothing, because the badge doesn't go sideways", () => {
// It sits inside the puck's own width — see "all three sit in the same place"
// above, which holds the stylesheet to it.
assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64);
});
test("the number here is the number the stylesheet draws", () => {
// Two files have to agree about one distance: holdem.css hangs the badge off the
// puck by it, render.js charges every chair in the ring for it. They are wired
// together by nothing but this test. Drift either way is silent — too small and
// badges land on neighbours again, too large and every puck on a nine-handed
// phone gives up a size step for clearance nothing needs.
const top = CSS.match(/\.blindBadge \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
const bottom = CSS.match(/\.seat\.seatBelowCentre \.blindBadge \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
assert.ok(top && bottom, "the badge should hang off both arcs");
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX,
"the same distance at both ends, or one arc is charged for the other's badge");
});
test("and it is counted once, not once per seat", () => {
// seatsCollide is given one box and compares centres against it, so the
// separation it asks for is one puck plus one badge. That is exactly right for
// two seats on the same arc each wearing one, and generous for the far commoner
// case where only one of the two is in the blinds at all. Counting it on both
// sides is what made a landscape table unsatisfiable the first time this was
// tried, back when that felt was 87px tall.
const puck = { width: 64, height: 40 };
const grew = Render.seatFootprint(puck).height - puck.height;
assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX);
assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX);
});
});
describe("with no browser to measure", () => {
test("nothing here throws", () => {
assert.equal(Render.measuredSeatBox(), null);
assert.equal(Render.seatFootprint(null), null);
});
});
+125
View File
@@ -0,0 +1,125 @@
// What the ring is allowed to be sized from.
//
// Two rows in every seat puck are usually empty — the hole-card line, and the pill that
// shows what a player has put in. Both used to hold their space anyway, and the pill,
// which has a background and a pill radius and side padding, painted an 8x11px green
// lozenge when it had nothing to say: measured on seven of nine seats at an ordinary
// table, a coloured mark on almost every seat standing for nothing.
//
// The space was not free either. seatRingRadii sizes the ring from the tallest puck on
// it, so two rows nobody had filled were setting how far apart the seats had to sit. On
// an iPhone 14 nine-handed the puck measured 78x88 with 25px of that reserved for
// nothing; collapsed, the width search settles on 78x63. Twelve-handed on a Galaxy S8
// the seats overlapped by 1556px² and now overlap by 87px².
//
// The collapse is only safe if the layout re-measures when one of those rows fills,
// because resolveSeatLayout caches its ten forced layouts against this signature. That
// is what is checked here: the cache key has to move when the puck's shape can, and
// stay still when it cannot — a signature that changed on every action would throw the
// cache away on every tap, which is the cost it exists to avoid.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, HoldemState, makeTable } = require("./load");
// seatShapeSignature reads Render.state, so a table has to be hung on it first.
const signatureOf = state => {
const before = Render.state;
Render.state = state;
try { return Render.seatShapeSignature(); } finally { Render.state = before; }
};
const table = () => makeTable({ seatCount: 6, stack: 200 });
describe("the signature the ring layout is cached against", () => {
test("moves when the first chips go in", () => {
// Before the blinds every pill is empty and the pucks are short; after them two
// seats are ~15px taller. A ring still sized from the first measurement is sized
// for a puck that no longer exists.
const state = table();
const empty = signatureOf(state);
state.seats[1].committed = 10;
assert.notEqual(signatureOf(state), empty,
"the ring would still be sized for pucks with no amount on them");
});
test("and when a hole card is entered", () => {
const state = table();
const none = signatureOf(state);
state.seats[0].cards = ["AS", "KC"];
assert.notEqual(signatureOf(state), none);
});
test("but not again for the second player to put chips in", () => {
// The whole point of a boolean over the table. The ring is sized from the
// tallest puck on it, so once anybody has an amount the answer cannot change —
// and re-measuring here would mean ten forced layouts on every single action.
const state = table();
state.seats[1].committed = 10;
const first = signatureOf(state);
state.seats[2].committed = 10;
assert.equal(signatureOf(state), first);
state.seats[3].committed = 250;
assert.equal(signatureOf(state), first);
});
test("nor when an amount merely changes size", () => {
const state = table();
state.seats[1].committed = 10;
const small = signatureOf(state);
state.seats[1].committed = 999999;
assert.equal(signatureOf(state), small,
"a wider number is the amount fitter's problem, not the ring's");
});
test("and it still moves for the things it always moved for", () => {
// Renaming can wrap a name onto a second line, and the button's chip floats
// into the name's first line, so both change how tall a puck measures.
const state = table();
const base = signatureOf(state);
state.seats[0].name = "Bartholomew Fairweather";
const renamed = signatureOf(state);
assert.notEqual(renamed, base);
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
assert.notEqual(signatureOf(state), renamed);
});
test("and two tables that differ only in where a name is split stay different", () => {
// The names are joined on a control character no name can contain. Joined on
// nothing, "ann" + "abel" and "anna" + "bel" are both "annabel" and one of those
// tables would be laid out with the other's measurements — different names, so
// different wrapping, so different puck heights.
//
// The first version of this used "Ann"/"Abel" against "Anna"/"Bel", which can
// never collide: the capital letters land in different places, so it passed with
// the separator taken out and proved nothing at all. Lower case is what makes it
// a real test.
const a = table();
const b = table();
a.seats[0].name = "ann"; a.seats[1].name = "abel";
b.seats[0].name = "anna"; b.seats[1].name = "bel";
assert.notEqual(signatureOf(a), signatureOf(b));
});
});
describe("a table nobody has sat down at", () => {
test("has no amounts and no cards on it", () => {
// The empty-table case is the one the collapse is most visible on, and it is
// also the one where a stray default could quietly keep both rows open forever.
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
assert.match(signatureOf(state), /\|$/,
"an untouched table should carry neither flag");
});
test("but a table with an ante on it does have an amount", () => {
// computeContribution answers for a seat that has never been given a stack —
// the table's base pot stands in for it — so a table dealt with "everyone antes
// 5" has amounts on its pucks before anybody acts, and the ring has to know.
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 6);
state.basePotAmount = 5;
state.seats.forEach(p => { p.inHand = true; });
assert.match(signatureOf(state), /a$/);
});
});
+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`);
}
});
});
});
+234
View File
@@ -0,0 +1,234 @@
// What the seat sheet shows before it is asked.
//
// The sheet is the second-most-used surface in the tool — every buy-in, every rename,
// every correction goes through it — and it was the least finished. Seven things were
// stacked flat at one weight and all open at once: a name, two status boxes, two
// hole-card slots, a stack, a five-box chip counter with an override and a total, and a
// list of custom fields. Measured on a 390px phone it came to 594px, and the three
// controls a dealer touches on nearly every visit were 132px of that — 22%. The chip
// counter alone was 174px, the tallest single thing in the sheet, and its five number
// boxes were told apart by five coloured dots and a `title` attribute, which is a
// tooltip, which is nothing at all on a phone.
//
// Two of these describe layout decisions and are checked as text — the markup for what
// sits in front of a dealer at rest, the stylesheet for the scrim and the order two
// bottom-anchored things stack in. The third describes behaviour and is run: which
// sections stand open is a question about the seat, and the answer is computed.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Render, makeTable } = require("./load");
const PAGE = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const template = id => {
const at = PAGE.indexOf(`<template id="${id}">`);
assert.ok(at >= 0, `there is no <template id="${id}">`);
return PAGE.slice(at, PAGE.indexOf("</template>", at));
};
// ---------------------------------------------------------------------------
describe("what the sheet puts in front of a dealer at rest", () => {
const seatForm = () => template("seatEditorTemplate");
// Everything the form holds that is *not* inside one of the folded sections. The
// <details> blocks are stripped whole, so what is left is the flat part.
const alwaysVisible = () => seatForm().replace(/<details[\s\S]*?<\/details>/g, "");
test("the flat part is the name, the two status boxes and the stack, and nothing else", () => {
const flat = alwaysVisible();
["seatNameInput", "inHandCheckbox", "foldCheckbox", "stackInput", "seatBuyIns"]
.forEach(cls => assert.ok(flat.includes(cls),
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
// And the four that used to be up here with them. Each is a job most tables do
// once a night or never, and between them they were two thirds of the sheet.
["cardSlot", "chipCounts", "amountOverrideInput", "customFields", "addFieldBtn"]
.forEach(cls => assert.ok(!flat.includes(cls),
`${cls} is open at rest again — it belongs inside a folded section`));
});
test("every folded section says what it holds and what is in it", () => {
const sections = [...seatForm().matchAll(/<details class="([^"]*seatSubPanel[^"]*)">([\s\S]*?)<\/details>/g)];
assert.equal(sections.length, 3, "the sheet folds cards, contribution and notes");
sections.forEach(([, className, markup]) => {
const summary = markup.match(/<summary>([\s\S]*?)<\/summary>/);
assert.ok(summary, `${className} has no summary to press`);
// A name a dealer would recognise, and a place for the one fact that says
// whether opening it is worth the tap.
assert.match(summary[1], /<span class="seatSubPanelName">[^<]+<\/span>/,
`${className}'s summary has no name in it`);
assert.match(summary[1], /class="seatGlance/,
`${className} says nothing while it is shut`);
});
});
test("the chip counter's values are text, not a tooltip", () => {
// Five identical number boxes, each worth a different amount, told apart by a
// coloured dot and a `title`. A title is a hover, and there is no hover here.
// It also meant the wrapping <label> had no text in it, so the boxes had no
// accessible name either — five unnamed spinbuttons in a row.
assert.match(template("chipCountTemplate"), /class="chipCountValue"/,
"the chip's value has to be in the row, where it can be read and announced");
});
});
describe("the sheet is in front of the table, and says so", () => {
test("there is a scrim, and it is furniture the desktop panel does not get", () => {
assert.match(PAGE, /id="seatSheetScrim"/, "the sheet has nothing behind it");
// Above 700px this element is not a scrim for anything — the seat form is a
// panel in the sidebar column, with the whole page still live around it.
const off = CSS.match(/\.seatSheetGrip,[\s\S]{0,120}?#seatSheetScrim\s*\{\s*display:\s*none/);
assert.ok(off, "the scrim has to be off by default, with the sheet's other furniture");
});
test("and it is not called an Overlay, because Modal would adopt it", () => {
// Modal.mount() watches every id ending in "Overlay" and makes a dialog of it.
// This one is a sidebar panel two thirds of the time.
assert.ok(!/id="seatSheet[A-Za-z]*Overlay"/.test(PAGE));
});
test("and it is put away with visibility, not with opacity", () => {
// The same lesson the sheet above it already learned about transforms, and the
// layout sweep caught it the same way: a fixed, full-window box at `opacity: 0`
// is invisible and still entirely there. bottomFurniture() read it as furniture
// standing 640px tall at the bottom of a 640px phone, in every state and on
// every screen, and asked scroll-padding to reserve the whole window for it.
const phone = CSS.slice(CSS.indexOf("@media (max-width: 700px)"));
const rule = phone.match(/#seatSheetScrim\s*\{[^}]*\}/);
assert.ok(rule, "the phone block should make a scrim of it");
assert.match(rule[0], /visibility:\s*hidden/,
"opacity alone leaves a full-window fixed element in the layout and the a11y tree");
});
test("the scrim stops where the sheet stops when a phone is on its side", () => {
// Fold, Call and Raise are a rail down the right-hand edge in landscape, and the
// sheet is deliberately kept clear of it. A scrim that reached across would dim
// the one part of the screen the sheet was making room for — and swallow its
// taps, which is worse.
const landscape = CSS.slice(CSS.indexOf("@media (orientation: landscape) and (max-height: 560px) and (max-width: 900px)"));
const rule = landscape.match(/#seatSheetScrim\s*\{[^}]*\}/);
assert.ok(rule, "the landscape block never tells the scrim about the rail");
assert.match(rule[0], /right:\s*var\(--actionRailW\)/);
});
test("the toast steps over the sheet, and the rule that says so comes last", () => {
// Two things are fixed to the bottom of a phone and the toast is the one that
// gives way. `body.hasActionBar #toastHost` and `body.seatSheetOpen #toastHost`
// have identical specificity, so this is decided by source order and by nothing
// else — a copy written above the other would look right and do nothing.
const bar = CSS.indexOf("body.hasActionBar #toastHost");
const sheet = CSS.indexOf("body.seatSheetOpen #toastHost");
assert.ok(bar > 0 && sheet > 0, "both rules should exist");
assert.ok(sheet > bar,
"at equal specificity the later rule wins, and the sheet is the taller of the two");
// Measured, not assumed: the height changes the moment a section is unfolded.
assert.match(CSS.slice(sheet, sheet + 240), /var\(--seatSheetHeight/);
});
});
// ---------------------------------------------------------------------------
// The form the disclosures are rendered into, reduced to the two things
// renderSeatDisclosures asks of it: find a section, find its glance.
const fakeForm = () => {
const sections = {};
for (const name of ["cardsPanel", "contributionPanel", "notesPanel"]) {
sections[`.${name}`] = {
open: false,
glance: null,
querySelector(selector) {
assert.equal(selector, ".seatGlance");
return { set textContent(text) { sections[`.${name}`].glance = text; } };
},
};
}
return { querySelector: selector => sections[selector] || null, sections };
};
const disclose = (state, player) => {
const form = fakeForm();
Render.state = state;
Render.renderSeatDisclosures(form, player);
return form.sections;
};
describe("which sections stand open is a question about the seat", () => {
test("a seat with nothing on it opens nothing", () => {
const state = makeTable({ seatCount: 4, stack: 200 });
const sections = disclose(state, state.seats[0]);
assert.deepEqual(
Object.values(sections).map(s => s.open), [false, false, false],
"an empty seat's sheet is three lines and the three fields above them");
});
test("a seat holding cards opens its cards, and names them while shut", () => {
const state = makeTable({ seatCount: 4 });
state.seats[0].cards = ["as", "kd"];
const sections = disclose(state, state.seats[0]);
assert.equal(sections[".cardsPanel"].open, true);
// Whatever formatCard makes of them — the point is that both are in it.
assert.match(sections[".cardsPanel"].glance, /A/);
assert.match(sections[".cardsPanel"].glance, /K/);
});
test("a table that asked to be shown the cards opens them before the first one", () => {
// Every seat at such a table is about to have two. Opening the slots after the
// cards arrive would be opening them one tap too late, every hand.
const state = makeTable({ seatCount: 4 });
state.trackBoard = true;
assert.equal(disclose(state, state.seats[0])[".cardsPanel"].open, true);
});
test("a seat with chips counted opens the count, with the total on the row", () => {
const state = makeTable({ seatCount: 4 });
const fiver = state.chipLegend.find(chip => chip.value === 5);
state.seats[0].chipCounts = { [fiver.id]: 4 };
const sections = disclose(state, state.seats[0]);
assert.equal(sections[".contributionPanel"].open, true);
assert.equal(sections[".contributionPanel"].glance, "$20");
});
test("a typed amount counts as something on the seat too", () => {
// Overriding the total is the other way of answering the same question, and a
// dealer who typed a number has to be able to see it without hunting.
const state = makeTable({ seatCount: 4 });
state.seats[0].amountOverride = 60;
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true);
});
test("and a zero someone actually typed is not the same as never having typed one", () => {
const state = makeTable({ seatCount: 4 });
state.seats[0].amountOverride = 0;
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true,
"0 is a value; only null means the field was left alone");
});
test("a seat with a note on it opens the notes and counts them", () => {
const state = makeTable({ seatCount: 4 });
state.seats[0].customFields = [{ label: "Owes", value: "$40" }, { label: "", value: "" }];
const sections = disclose(state, state.seats[0]);
assert.equal(sections[".notesPanel"].open, true);
assert.equal(sections[".notesPanel"].glance, "1 note",
"an empty row the dealer has not filled in yet is not a note");
});
test("a section is only ever pushed open, never pulled shut", () => {
// This runs again after every keystroke in the sheet. A dealer who opened the
// chip counter to start counting would have it shut under them on the first
// digit if this ever wrote `open = false`.
const state = makeTable({ seatCount: 4 });
const form = fakeForm();
form.sections[".notesPanel"].open = true;
Render.state = state;
Render.renderSeatDisclosures(form, state.seats[0]);
assert.equal(form.sections[".notesPanel"].open, true);
});
});
+119
View File
@@ -0,0 +1,119 @@
// The seat holding the dealer button has to still be a seat you can tap.
//
// On a fresh table the button is on seat 1, and seat 1 is where the first player goes.
// Measured on a 390px phone, a 72x46 puck: the dealer chip's invisible tap pad ran from
// the puck's own centre to its right edge and from its top to 3px off its bottom. The
// chip's click handler calls stopPropagation, so every tap that landed there reached
// nothing at all — the seat holding the button kept 52% of its own face, 130 of 276
// sampled points going to a chip whose only answer was "give the button to the seat
// that has it". Seating the first player, on the seat a dealer reaches for first, did
// nothing. Every other seat kept 97%, and the button moves every hand, so the dead puck
// moved around the table with it.
//
// Three things had to agree for that to happen and they are the three checked here: the
// pad exists, the chip under it is live, and its handler eats the event. The pad is gone
// (nothing needs it — no finger can reach any chip), the chip that has the button is
// inert to the pointer, and the handler bails rather than swallowing a tap it has no
// answer for.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const src = (...parts) => fs.readFileSync(path.join(__dirname, "..", "..", "JoshHeaps.Net", ...parts), "utf8");
const CSS = src("wwwroot", "css", "casino", "holdem.css");
const RENDER = src("wwwroot", "js", "casino", "holdem", "render.js");
const MAIN = src("wwwroot", "js", "casino", "holdem", "main.js");
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
// The body of every rule written at exactly this selector, concatenated. Every one of
// them, because the phone block redeclared the chip's pad under the same name and it
// was the redeclared copy — 44px centred on the disc — that reached furthest in.
const rulesFor = selector => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "gm");
const bodies = [];
let found;
while ((found = re.exec(CSS)) !== null) bodies.push(found[2]);
return bodies;
};
// ---------------------------------------------------------------------------
describe("the seat under the dealer button is still a seat", () => {
test("the chip has no invisible pad reaching back over its own puck", () => {
// Not "a smaller pad". A fixed pad cannot be safe at every puck size: the seat
// search hands out pucks from 48px wide to 100-odd depending on the screen and
// the number of chairs, and any pad big enough to matter on the large one
// covers the middle of the small one.
assert.equal(rulesFor(".dealerChip::after").length, 0,
"the dealer chip grew a tap pad again — see the note above .dealerChip in holdem.css");
});
test("the chip that has the button takes no taps", () => {
const bodies = rulesFor(".dealerChip.active");
assert.ok(bodies.length > 0, ".dealerChip.active has no rule at all");
assert.ok(bodies.some(body => /pointer-events:\s*none/.test(body)),
"the seat holding the button has a live control sitting on it");
});
test("the other chips stay inert until a pointer that can hover arrives", () => {
// This is what makes the pad unnecessary rather than merely unwanted. On touch
// there is no hover, so no ghost chip is reachable by a finger and the only live
// chip is the inert one above. Nothing a thumb can press is 22px.
const ghost = rulesFor(".dealerChip:not(.active)");
assert.ok(ghost.some(body => /pointer-events:\s*none/.test(body)),
"a chip nobody can see is taking taps");
const hover = CSS.slice(CSS.indexOf("@media (hover: hover)"));
const revealed = hover.match(/\.seat:hover\s+\.dealerChip:not\(\.active\)\s*\{([^}]*)\}/);
assert.ok(revealed, "hovering a seat no longer offers to put the button there");
assert.match(revealed[1], /pointer-events:\s*auto/,
"the mouse path to moving the button is gone");
});
test("and the handler does not swallow a tap it has no answer for", () => {
// stopPropagation is what turned a pointless control into a hole. It still runs
// for the eight seats the button could move to; the ninth returns first.
const handler = RENDER.slice(RENDER.indexOf('querySelector(".dealerChip").addEventListener'));
const body = handler.slice(0, handler.indexOf("});"));
const guard = body.indexOf("dealerSeatIndex");
const stop = body.indexOf("stopPropagation");
assert.ok(guard >= 0, "the chip's click handler does not check whose button it is");
assert.ok(stop >= 0, "the chip stopped guarding the seat behind it from the other chips");
assert.ok(guard < stop,
"the guard has to come first, or the tap is eaten before anyone decides it meant nothing");
});
test("moving the button to where it already is is not a move", () => {
// The keyboard can still reach the chip, and the same no-op would take the
// blinds off the felt and re-post the same chips to announce that nothing
// happened. The seat menu has always hidden "Give the button" on the seat that
// holds it; this is the same rule where the money is.
const from = MAIN.indexOf("makeDealer: index =>");
assert.ok(from >= 0, "makeDealer is gone or renamed");
const body = MAIN.slice(from, MAIN.indexOf("blindsArePosted", from));
assert.match(body, /if\s*\(\s*index\s*===\s*state\.dealerSeatIndex\s*\)\s*return/,
"makeDealer will re-post the blinds to move the button nowhere");
});
});
describe("the sweep can see this kind of failure now", () => {
test("a control inside a seat counts as a rival for the seat's tap", () => {
// The layout sweep already had a check named "a seat's centre belongs to that
// seat", written after the same chip's pad grew *outward* and started taking
// taps from the seat next door. It ran clean through all of this: it waved
// through anything the seat contained, and the chip is inside the seat. The
// check that existed for this exact bug could not see the second half of it.
const from = AUDIT.indexOf("seatOwnership()");
assert.ok(from >= 0, "the seat-ownership check is gone");
const body = AUDIT.slice(from, AUDIT.indexOf("oneDealerButton()", from));
assert.match(body, /seat\.contains\(rival\)/,
"a seat's own control is being waved through again");
assert.doesNotMatch(body, /if\s*\(\s*!hit\s*\|\|\s*seat\.contains\(hit\)/,
"the early return that hid this is back: it exempts every descendant, chip included");
});
});
+129
View File
@@ -0,0 +1,129 @@
// How wide a seat puck is allowed to get, and on which screens.
//
// The felt grows with the window and the pucks did not: 96px was the only ceiling there
// was, so nine-handed the seats covered 19% of the felt on a phone, 11% on a 1280px
// desktop and 8% at 1440px and beyond — with the stack figure a dealer reads sitting at
// 10px on a 2560px monitor, the same 10px as on a 360px phone.
//
// Two things stop the fix from costing more than it buys, and both are checked here.
//
// Every candidate width is a forced layout, and raising the ceiling for everybody took
// the layout pass from 24ms to 73ms on a twelve-handed desktop — so a screen that cannot
// use the extra widths must not measure them. And a 144px puck on an iPhone's 278px ring
// is half the width of the table: the first version of this let phones through, and
// six-handed all six seats sat over the rail by 15px against 5px before, reading as a
// stack of cards rather than a table with people around it.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
// Ring boxes read off the running page, not invented. #seatsLayer, nine-handed, at the
// viewports the sweep visits.
const RINGS = {
"Galaxy S8": { width: 233, height: 282 },
"iPhone 14": { width: 263, height: 470 },
"Pro Max": { width: 303, height: 535 },
"phone sideways": { width: 565, height: 257 },
iPad: { width: 689, height: 689 },
desktop: { width: 857, height: 617 },
"desktop 1440": { width: 992, height: 714 },
};
const SMALL = ["Galaxy S8", "iPhone 14", "Pro Max", "phone sideways"];
const LARGE = ["iPad", "desktop", "desktop 1440"];
describe("which felts may have wider pucks at all", () => {
test("no phone does, held either way up", () => {
// The guarantee the phones are owed: they measure the widths they always
// measured and settle on the puck they always settled on.
SMALL.forEach(name => {
assert.ok(Render.roomyWidthCeiling(RINGS[name]) <= Render.SEAT_WIDTH_MAX,
`${name} would be allowed a ${Render.roomyWidthCeiling(RINGS[name])}px puck`);
[2, 4, 6, 9, 12].forEach(count => assert.equal(
Render.roomForWiderPucks(count, RINGS[name]), false,
`${name} ${count}-handed`));
});
});
test("and every tablet and desktop does", () => {
LARGE.forEach(name => {
assert.ok(Render.roomyWidthCeiling(RINGS[name]) > Render.SEAT_WIDTH_MAX, name);
});
});
test("with a comfortable margin either side of the line", () => {
// The rule is a fifth of the shorter span, and the point of five is that it is
// not near either group. If a change to the felt ever moves these together,
// this says so before the phones quietly start paying for it.
const biggestSmall = Math.max(...SMALL.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
const smallestLarge = Math.min(...LARGE.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
assert.ok(smallestLarge > biggestSmall * 1.8,
`${biggestSmall}px and ${smallestLarge}px are too close for one divisor to separate`);
// Every divisor that keeps the phones out is at least the widest phone span over
// the ordinary ceiling; every one that lets the tablets in is below the narrowest
// large span over the same. 303/96 = 3.2 and 617/96 = 6.4, and five is between.
const floor = biggestSmall / Render.SEAT_WIDTH_MAX;
const cap = smallestLarge / Render.SEAT_WIDTH_MAX;
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO >= floor,
`${Render.SEAT_ROOMY_SPAN_RATIO} lets a phone through; it has to be at least ${floor.toFixed(2)}`);
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO < cap,
`${Render.SEAT_ROOMY_SPAN_RATIO} shuts the tablets out; it has to be under ${cap.toFixed(2)}`);
});
test("and a felt with no size at all is not one of them", () => {
assert.equal(Render.roomForWiderPucks(9, null), false);
assert.equal(Render.roomForWiderPucks(9, { width: 0, height: 0 }), false);
});
});
describe("the widths a felt actually measures", () => {
const widthsFor = (name, count) => Render.seatWidthCandidates(count, RINGS[name]);
test("a phone measures exactly the ten it measured before", () => {
const expected = [];
for (let w = Render.SEAT_WIDTH_MAX; w > Render.SEAT_WIDTH_MIN; w -= Render.SEAT_WIDTH_STEP) {
expected.push(w);
}
expected.push(Render.SEAT_WIDTH_MIN);
SMALL.forEach(name => [6, 9, 12].forEach(count =>
assert.deepEqual(widthsFor(name, count), expected, `${name} ${count}-handed`)));
});
test("a desktop measures a handful more, and no more than a handful", () => {
const extra = widthsFor("desktop 1440", 9).length - widthsFor("Galaxy S8", 9).length;
assert.ok(extra > 0, "the wider widths never got offered");
assert.ok(extra <= 6, `${extra} extra forced layouts is more than this is worth`);
});
test("never wider than the felt's own ceiling", () => {
Object.keys(RINGS).forEach(name => [2, 6, 9, 12].forEach(count => {
const widest = Math.max(...widthsFor(name, count));
assert.ok(widest <= Math.max(Render.SEAT_WIDTH_MAX, Render.roomyWidthCeiling(RINGS[name])),
`${name} ${count}-handed offered ${widest}px`);
assert.ok(widest <= Render.SEAT_WIDTH_ROOMY_MAX);
}));
});
test("always widest first, always down to the floor", () => {
// The search takes the first width that fits, so the order is the whole
// algorithm — and the floor has to be on the list or a crowded table has
// nothing left to fall back to.
Object.keys(RINGS).forEach(name => [2, 9, 12].forEach(count => {
const widths = widthsFor(name, count);
widths.forEach((w, i) => {
if (i === 0) return;
assert.ok(w < widths[i - 1], `${name}: ${widths.join(", ")} is not descending`);
});
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
}));
});
test("and a crowded table on a big felt is told no like any other", () => {
// Room on the felt is necessary, not sufficient: the seats still have to clear
// each other. Enough of them on any ring and the wider widths stop being
// offered, which is the arithmetic doing the refusing rather than the
// measurement having to.
const crowded = [24, 30, 40].map(count => Render.roomForWiderPucks(count, RINGS.desktop));
assert.deepEqual(crowded, [false, false, false]);
});
});
+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");
});
});
+110
View File
@@ -0,0 +1,110 @@
// How wide the ring of seats is allowed to be.
//
// It sat at a flat 44% by 42% of the seat layer at every size — two numbers picked
// against a phone, then asked to hold for a 754x543 felt on a desktop and a 686x141
// one on a phone held sideways. A fixed pair can only be right for one ratio of puck
// to felt, so measured on the running page:
//
// 1280x900, nine-handed five of nine pucks past the table's outer edge, worst 49px
// 844x390, six-handed all six, worst 20px
// 430x932, six-handed two, worst 12px
//
// Seats floating off the felt onto the page read as the table being too small for the
// game rather than as players at a rail, and it was a different amount of wrong on
// every screen — which is what made it look like a mistake instead of a choice.
//
// The radius is derived from what has to fit now. Whether it *actually* fits is a
// question about a laid-out browser, and it is checked there: uiAudit's "seats and
// chips stay on the table" runs at every viewport in the sweep. What is checked here
// is the contract that function has to honour, including the two ways it is allowed
// to give up.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
describe("with no browser to measure", () => {
test("it falls back to the ring the tool has always used", () => {
// Every call from this suite has no DOM, and so does the very first render,
// before the felt has been laid out. Neither should throw or return nonsense.
assert.deepEqual(Render.seatRingRadii(), { rx: Render.RING_MAX_RX, ry: Render.RING_MAX_RY });
});
test("so seatPosition still answers", () => {
const pos = Render.seatPosition(0, 9, 1);
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top));
});
test("and nothing is measured for the puck height either", () => {
assert.equal(Render.tooTallForTheTable(), false);
});
});
describe("the ceiling and the floor", () => {
test("the old ring is the widest it can ever be", () => {
// Nothing gets looser than it was; this can only pull seats in.
assert.ok(Render.RING_MAX_RX <= 44);
assert.ok(Render.RING_MAX_RY <= 42);
});
test("and it never collapses the ring into the pot", () => {
// A puck wider than the table it sits on has no honest answer. Overhanging is
// the lesser evil — seats stacked on the pot would be unreadable, and the
// density rule shrinks the pucks before it ever gets this far.
assert.ok(Render.RING_MIN_R > 0);
assert.ok(Render.RING_MIN_R < Render.RING_MAX_RY);
});
test("with a margin that keeps the outermost seat off the rail", () => {
assert.ok(Render.SEAT_EDGE_MARGIN_PX > 0);
});
});
describe("the ring the radii describe", () => {
const spread = (radii, count = 9, aspect = 1.4) =>
Render.ringPositions(count, radii.rx, radii.ry, aspect);
test("a smaller radius puts every seat nearer the middle", () => {
const wide = spread({ rx: 44, ry: 42 });
const tight = spread({ rx: 30, ry: 28 });
wide.forEach((seat, i) => {
const wideOut = Math.hypot(seat.left - 50, seat.top - 50);
const tightOut = Math.hypot(tight[i].left - 50, tight[i].top - 50);
assert.ok(tightOut < wideOut, `seat ${i + 1} should have come in`);
});
});
test("and the seats stay evenly spread however far in they are", () => {
// Pulling the ring in must not bunch them: the spacing rule is arc length, and
// shrinking an ellipse uniformly keeps every gap in proportion.
const gaps = ring => ring.map((p, i) => {
const next = ring[(i + 1) % ring.length];
return Math.hypot(p.left - next.left, p.top - next.top);
});
const tight = gaps(spread({ rx: 30, ry: 28 }));
const smallest = Math.min(...tight), largest = Math.max(...tight);
assert.ok(largest / smallest < 1.35, `gaps ranged ${smallest.toFixed(1)}..${largest.toFixed(1)}`);
});
});
describe("the bets follow the seats", () => {
test("a chip sits on its own player's spoke, proportionally in", () => {
// The chip is a fraction of the seat's reach rather than of the felt, so when
// the ring pulls in the chips come with it instead of being left by the rail.
const radii = { rx: 30, ry: 28 };
const seat = Render.seatPosition(2, 8, 1.4, radii);
const bet = Render.betPosition(2, 8, 1.4, radii);
const seatOut = Math.hypot(seat.left - 50, seat.top - 50);
const betOut = Math.hypot(bet.left - 50, bet.top - 50);
assert.ok(betOut < seatOut, "the chip is nearer the pot than the player is");
assert.ok(betOut > seatOut * 0.4, "but still out on the player's side of the felt");
});
test("and it is the same spoke, not just the same distance", () => {
const radii = { rx: 30, ry: 28 };
const seat = Render.seatPosition(5, 9, 1.2, radii);
const bet = Render.betPosition(5, 9, 1.2, radii);
const angleOf = p => Math.atan2(p.top - 50, p.left - 50);
assert.ok(Math.abs(angleOf(seat) - angleOf(bet)) < 0.01);
});
});
+119
View File
@@ -0,0 +1,119 @@
// Which fields replace their contents when a dealer reaches for them.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money } = require("./load");
// Enough of an input to answer the question, without a DOM.
const field = (props = {}) => ({ tagName: "INPUT", type: "text", inputMode: "", ...props });
test("a number box selects", () => {
assert.equal(Money.selectsOnFocus(field({ type: "number" })), true);
});
test("a decimal keypad box selects", () => {
// The raise pad's amount is type=text with inputmode=decimal, so the phone shows
// a number pad without the browser's spinner arrows.
assert.equal(Money.selectsOnFocus(field({ type: "text", inputMode: "decimal" })), true);
});
test("a name does not", () => {
// Selecting all of a name on focus is the same mistake pointing the other way:
// a name is a thing you edit, not a suggestion you replace.
assert.equal(Money.selectsOnFocus(field({ type: "text" })), false);
});
test("a colour swatch does not", () => {
assert.equal(Money.selectsOnFocus(field({ type: "color" })), false);
});
test("a checkbox does not", () => {
assert.equal(Money.selectsOnFocus(field({ type: "checkbox" })), false);
});
test("a textarea does not, whatever it is asked", () => {
assert.equal(Money.selectsOnFocus({ tagName: "TEXTAREA", type: "text", inputMode: "decimal" }), false);
});
test("a select does not", () => {
assert.equal(Money.selectsOnFocus({ tagName: "SELECT", type: "select-one" }), false);
});
test("case doesn't matter", () => {
assert.equal(Money.selectsOnFocus(field({ type: "NUMBER" })), true);
assert.equal(Money.selectsOnFocus(field({ type: "TEXT", inputMode: "DECIMAL" })), true);
});
test("nothing at all is not a field", () => {
assert.equal(Money.selectsOnFocus(null), false);
assert.equal(Money.selectsOnFocus(undefined), false);
});
test("a field with no type stated is a text box, so it does not", () => {
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
});
// ---- A scroll is not an edit ------------------------------------------------
//
// A focused `<input type="number">` is a spinbutton, and the browser lets the mouse
// wheel drive it. Measured on the running page at 1280x900 with the sidebar open: focus
// the small blind and scroll, $5 becomes $5.50. Focus a seat's stack and scroll, $500
// becomes $501. Both go straight through to the saved table, because these fields save
// on `input` like everything else here.
//
// Nothing is announced and nothing looks wrong. The tool has invented a chip, and the
// gesture that did it was a dealer reaching for another panel.
//
// The rule is narrow on purpose. Every other way of driving a spinbutton is deliberate —
// the arrow keys, the stepper arrows, typing — and none of them is touched. Scrolling is
// the one actuation nobody means.
describe("a wheel over a field the dealer is in", () => {
const box = (type, tag = "INPUT") => ({ tagName: tag, type });
test("a focused number box would be edited by it", () => {
const stack = box("number");
assert.equal(Money.wheelWouldEdit(stack, stack), true);
});
test("the same box, not focused, would not", () => {
// An unfocused spinbutton ignores the wheel. There is nothing to defend, and
// blurring something that is not focused is a no-op with a side effect.
const stack = box("number");
assert.equal(Money.wheelWouldEdit(stack, null), false);
assert.equal(Money.wheelWouldEdit(stack, box("number")), false);
});
test("the amount boxes that already moved off type=number are safe as they are", () => {
// Add chips, the raise pad and Quick Split use text + inputmode="decimal", which
// is not a spinbutton and cannot be scrolled into a different number.
const amount = { tagName: "INPUT", type: "text", inputMode: "decimal" };
assert.equal(Money.wheelWouldEdit(amount, amount), false);
// Still an amount field for the other rule, though — the two questions are
// different and this one must not quietly answer both.
assert.equal(Money.selectsOnFocus(amount), true);
});
test("a name, a note and a table are left alone", () => {
const name = { tagName: "INPUT", type: "text" };
const notes = { tagName: "TEXTAREA", type: "textarea" };
assert.equal(Money.wheelWouldEdit(name, name), false);
assert.equal(Money.wheelWouldEdit(notes, notes), false);
assert.equal(Money.wheelWouldEdit(null, null), false);
assert.equal(Money.wheelWouldEdit(undefined, undefined), false);
});
test("the type is read case-insensitively, the way an attribute comes back", () => {
const shouty = box("NUMBER");
assert.equal(Money.wheelWouldEdit(shouty, shouty), true);
});
test("a select is a spinbutton to nobody", () => {
// The page has one <select> — which way the blinds move — and it was the obvious
// next suspect. It isn't one: focused, hovered and scrolled 300px in Chromium it
// stays on "clockwise", because a select only takes the wheel while its dropdown
// is open. Checked rather than assumed, and left out rather than defended
// against, so this rule keeps meaning exactly one thing.
const select = { tagName: "SELECT", type: "select-one" };
assert.equal(Money.wheelWouldEdit(select, select), false);
});
});
+124
View File
@@ -0,0 +1,124 @@
// Turning the standings into "who hands cash to whom".
const test = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, SettleUp, makeTable } = require("./load");
const standings = pairs => pairs.map(([name, cashPosition]) => ({ name, cashPosition }));
const lines = plan => plan.payments.map(p => `${p.from} -> ${p.to} ${Money.format(p.amount)}`);
const paidTo = (plan, name) =>
plan.payments.filter(p => p.to === name).reduce((sum, p) => sum + p.amount, 0);
const paidBy = (plan, name) =>
plan.payments.filter(p => p.from === name).reduce((sum, p) => sum + p.amount, 0);
test("one loser, one winner", () => {
const plan = SettleUp.plan(standings([["Ann", 40], ["Ben", -40]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $40"]);
assert.equal(plan.balanced, true);
});
test("everybody flat means nothing to do", () => {
const plan = SettleUp.plan(standings([["Ann", 0], ["Ben", 0]]));
assert.deepEqual(plan.payments, []);
assert.equal(plan.balanced, true);
});
test("nobody at all means nothing to do", () => {
const plan = SettleUp.plan([]);
assert.deepEqual(plan.payments, []);
assert.equal(plan.balanced, true);
});
test("each player ends up square", () => {
const table = standings([
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
]);
const plan = SettleUp.plan(table);
table.forEach(row => {
const settled = paidTo(plan, row.name) - paidBy(plan, row.name);
assert.equal(settled, row.cashPosition, `${row.name} should end up square`);
});
assert.equal(plan.balanced, true);
});
test("at most one payment fewer than there are people", () => {
const table = standings([
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
]);
const plan = SettleUp.plan(table);
assert.ok(plan.payments.length <= table.length - 1,
`${plan.payments.length} payments for ${table.length} players`);
});
test("a player who owes two people has both lines together", () => {
// The list gets read out one player at a time, so a payer's obligations must not
// be split up by somebody else's.
const plan = SettleUp.plan(standings([["Ann", 50], ["Cal", 30], ["Ben", -80]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $50", "Ben -> Cal $30"]);
});
test("cents survive", () => {
const plan = SettleUp.plan(standings([["Ann", 0.1], ["Ben", 0.2], ["Cal", -0.3]]));
assert.deepEqual(lines(plan), ["Cal -> Ben $0.20", "Cal -> Ann $0.10"]);
assert.equal(plan.balanced, true);
});
test("more chips than cash leaves somebody unpaid, and says so", () => {
// The table is $25 up on the bank — an unrecorded rebuy or a miscount. The winners
// cannot all be paid, and pretending otherwise is how a real person ends up short.
const plan = SettleUp.plan(standings([["Ann", 100], ["Ben", -75]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $75"]);
assert.equal(plan.shortBy, 25);
assert.equal(plan.overBy, 0);
assert.equal(plan.balanced, false);
});
test("less chips than cash leaves a payer with nowhere to send it", () => {
const plan = SettleUp.plan(standings([["Ann", 50], ["Ben", -75]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $50"]);
assert.equal(plan.overBy, 25);
assert.equal(plan.shortBy, 0);
assert.equal(plan.balanced, false);
});
test("no payment is ever for nothing", () => {
const plan = SettleUp.plan(standings([
["Ann", 60], ["Ben", -60], ["Cal", 0], ["Dee", 0],
]));
assert.ok(plan.payments.every(p => p.amount > 0));
assert.equal(plan.payments.length, 1);
});
test("nobody pays themselves", () => {
const plan = SettleUp.plan(standings([
["Ann", 90], ["Ben", -30], ["Cal", -30], ["Dee", -30],
]));
assert.ok(plan.payments.every(p => p.from !== p.to));
});
test("the payments total what the winners are owed", () => {
const table = standings([["Ann", 42.5], ["Ben", 17.5], ["Cal", -60]]);
const plan = SettleUp.plan(table);
const moved = plan.payments.reduce((sum, p) => sum + p.amount, 0);
assert.equal(Money.round(moved), 60);
});
test("it settles a real table's standings, not just hand-made numbers", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].buyIns = 100;
state.seats[1].buyIns = 100;
state.seats[2].buyIns = 100;
state.seats[0].stack = 175;
state.seats[1].stack = 90;
state.seats[2].stack = 35;
const rows = HoldemState.sessionStandings(state);
const plan = SettleUp.plan(rows);
// Bank balances — 300 in, 300 on the table — so everyone can be paid.
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
assert.equal(plan.balanced, true);
rows.forEach(row => {
assert.equal(paidTo(plan, row.name) - paidBy(plan, row.name), row.cashPosition);
});
});
+51
View File
@@ -0,0 +1,51 @@
// The legend and the key map must not drift apart.
//
// F, C and R are printed on the buttons they drive. A and U were on nothing at all,
// so a dealer who learned the first three from the action bar had no way to find out
// the other two existed. The guard against that happening again is this file.
const test = require("node:test");
const assert = require("node:assert");
const path = require("path");
const Shortcuts = require(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
test("every shortcut has a line in the legend", () => {
const documented = new Set(Shortcuts.LEGEND.map(entry => entry.action));
const missing = [...new Set(Object.values(Shortcuts.KEYS))].filter(action => !documented.has(action));
assert.deepEqual(missing, [], "a shortcut with no legend line is one nobody can find");
});
test("the legend doesn't describe shortcuts that don't exist", () => {
const real = new Set(Object.values(Shortcuts.KEYS));
const invented = Shortcuts.LEGEND.map(entry => entry.action).filter(action => !real.has(action));
assert.deepEqual(invented, []);
});
test("every legend line names its keys and says what they do", () => {
Shortcuts.LEGEND.forEach(entry => {
assert.ok(Array.isArray(entry.keys) && entry.keys.length > 0, `${entry.action} has no keys`);
assert.ok(entry.keys.every(key => typeof key === "string" && key.trim()), `${entry.action} has a blank key`);
assert.ok(entry.what && entry.what.trim().length > 3, `${entry.action} has no description`);
});
});
test("the keys named match the keys bound", () => {
// "Space" and "Enter" both drive advance; the map holds them as " " and "enter".
const spoken = { space: " ", enter: "enter", esc: "escape" };
Shortcuts.LEGEND.forEach(entry => {
entry.keys.forEach(label => {
const key = spoken[label.toLowerCase()] || label.toLowerCase();
assert.equal(Shortcuts.KEYS[key], entry.action,
`the legend says ${label} is ${entry.action}, the map disagrees`);
});
});
});
test("no two lines claim the same key", () => {
const seen = new Set();
Shortcuts.LEGEND.forEach(entry => entry.keys.forEach(key => {
assert.ok(!seen.has(key.toLowerCase()), `${key} is listed twice`);
seen.add(key.toLowerCase());
}));
});
+110
View File
@@ -0,0 +1,110 @@
// The keyboard shortcuts, and the one rule that keeps them from stealing a press.
//
// Shortcuts talks to `document` directly, so these tests stand a small fake one up
// rather than a whole DOM: the only things it asks about are what has focus and
// whether an overlay is open.
const test = require("node:test");
const assert = require("node:assert");
const path = require("path");
const Shortcuts = require(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
// A stand-in for a focused element. `matches` answers the same selector list the
// real one would, which is the whole of what focusedControlOwnsKey looks at.
function element(tagName, { selectors = [], isContentEditable = false } = {}) {
return {
tagName,
isContentEditable,
matches: selector => selectors.some(s => selector.includes(s)),
};
}
const BUTTON = element("BUTTON", { selectors: ["button"] });
const SUMMARY = element("SUMMARY", { selectors: ["summary"] });
const LINK = element("A", { selectors: ["a[href]"] });
const DIV = element("DIV");
const TEXT_FIELD = element("INPUT");
// Runs one keypress against a fresh Shortcuts and reports which action fired.
function press(key, { focused = DIV, overlays = [] } = {}) {
const fired = [];
let defaultPrevented = false;
globalThis.document = {
activeElement: focused,
addEventListener() {},
querySelectorAll: () => overlays,
};
const handlers = {};
for (const action of Object.values(Shortcuts.KEYS)) {
handlers[action] = () => { fired.push(action); return true; };
}
Shortcuts.handlers = handlers;
Shortcuts.onKey({ key, preventDefault: () => { defaultPrevented = true; } });
return { fired: fired[0] || null, defaultPrevented };
}
test.after(() => { delete globalThis.document; });
test("Enter on a focused button is left to the button", () => {
assert.equal(press("Enter", { focused: BUTTON }).fired, null);
});
test("Space on a focused button is left to the button", () => {
assert.equal(press(" ", { focused: BUTTON }).fired, null);
});
test("a key left to the control is not swallowed either", () => {
// preventDefault would stop the browser activating the button, which is the
// half of the bug that turned "wrong action" into "no action".
assert.equal(press("Enter", { focused: BUTTON }).defaultPrevented, false);
});
test("summary and links own Enter and Space too", () => {
assert.equal(press("Enter", { focused: SUMMARY }).fired, null);
assert.equal(press(" ", { focused: LINK }).fired, null);
});
test("Space still advances when nothing pressable has focus", () => {
assert.equal(press(" ", { focused: DIV }).fired, "advance");
});
test("Enter still advances when nothing pressable has focus", () => {
assert.equal(press("Enter", { focused: DIV }).fired, "advance");
});
test("the letter shortcuts stay live with a button focused", () => {
// A button does nothing with "f", so there is no press to hand back — and a
// dealer whose hand is on the keys shouldn't lose them to a stray focus ring.
assert.equal(press("f", { focused: BUTTON }).fired, "fold");
assert.equal(press("c", { focused: BUTTON }).fired, "checkOrCall");
assert.equal(press("r", { focused: BUTTON }).fired, "raise");
assert.equal(press("a", { focused: BUTTON }).fired, "allIn");
assert.equal(press("u", { focused: BUTTON }).fired, "undo");
});
test("typing still silences everything but Escape", () => {
assert.equal(press("f", { focused: TEXT_FIELD }).fired, null);
assert.equal(press("Escape", { focused: TEXT_FIELD }).fired, "cancel");
});
test("an open overlay still owns the keyboard", () => {
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: false }] }).fired, null);
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: true }] }).fired, "fold");
});
test("a focused control wins before the overlay check, not after", () => {
// Both would decline here; the point is that neither path fires an action.
assert.equal(press("Enter", { focused: BUTTON, overlays: [{ hidden: false }] }).fired, null);
});
test("modified keys are never shortcuts", () => {
globalThis.document = { activeElement: DIV, addEventListener() {}, querySelectorAll: () => [] };
const fired = [];
Shortcuts.handlers = { advance: () => { fired.push("advance"); return true; } };
Shortcuts.onKey({ key: " ", ctrlKey: true, preventDefault() {} });
assert.deepEqual(fired, []);
});
+147
View File
@@ -0,0 +1,147 @@
// Who the tool asks to turn over, and in what order.
//
// The board half of the card feature always worked: every street, a sheet asks which
// cards came off the deck. The hands half never did. A dealer entered five community
// cards, pressed the showdown button, and the pot wizard told them "Chris, Mike and
// Alex haven't turned over, so who won is still yours to say" — with nothing in the
// sheet to turn anybody over with. The only way in was to close the wizard, tap a seat
// puck, find the card slots in the seat editor, and repeat it for each player, with the
// pot sitting on the table.
//
// So the showdown button now asks, the same way the street button does. This file is
// the list it asks from. The sheet itself is DOM and is checked in the browser; the
// question of who is on the list, and in which order, is state and lives here.
//
// Three rules hold it together:
//
// Only when asked for. The setting is off by default and the list is empty when it
// is off, so a table that never wanted this never sees a sheet.
//
// Only what isn't known. A hand already entered is an answer already given, and
// asking again offers to overwrite it.
//
// In the order the cards actually come over — the showdown order the action bar
// already announces, then round the table from there. A dealer looking at the seat
// the bar just named should not be asked about the one behind them.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, Betting, HoldemState, makeTable } = require("./load");
// A hand played to the river with everyone still in, which is the only shape that
// has a showdown to ask about.
const atShowdown = ({ seats = 4, tracking = true } = {}) => {
const state = makeTable({ seatCount: seats, stack: 500, smallBlind: 5, bigBlind: 10 });
Betting.beginHand(state);
state.street = "river";
state.board = ["2C", "7D", "9S", "JH", "3S"];
state.trackBoard = tracking;
state.seats.forEach(player => { player.hasActed = true; });
return state;
};
const queue = state => Render.showdownCardQueue(state);
describe("only when the dealer asked to be asked", () => {
test("the setting off means nobody is asked anything", () => {
assert.deepStrictEqual(queue(atShowdown({ tracking: false })), []);
});
test("the setting on asks everyone still in", () => {
assert.strictEqual(queue(atShowdown()).length, 4);
});
test("a table nobody is sitting at is not a showdown", () => {
const state = atShowdown();
state.seats.forEach(player => { player.inHand = false; });
assert.deepStrictEqual(queue(state), []);
});
});
describe("a hand won without a showdown", () => {
// Everyone else folded, so there is one hand left and it never comes over. The
// bar says "Award pot to Chris" here, not "Showdown", and asking Chris to turn
// over a hand nobody paid to see is asking for a card the tool will never use.
test("one player left is asked for nothing", () => {
const state = atShowdown();
[1, 2, 3].forEach(i => { state.seats[i].folded = true; });
assert.deepStrictEqual(queue(state), []);
});
test("two left is a showdown, however it was arrived at", () => {
const state = atShowdown();
[2, 3].forEach(i => { state.seats[i].folded = true; });
assert.strictEqual(queue(state).length, 2);
});
// Everybody all-in on the flop is still a showdown: the cards go on their backs,
// the board runs out, and the hands are read exactly as they would have been.
test("all-in with the betting finished is still asked about", () => {
const state = atShowdown();
state.seats.forEach(player => { player.stack = 0; });
assert.ok(!Betting.canStillBet(state));
assert.strictEqual(queue(state).length, 4);
});
});
describe("only the hands it doesn't already have", () => {
test("a hand already entered is not asked for again", () => {
const state = atShowdown();
state.seats[1].cards = ["AS", "AH"];
assert.ok(!queue(state).includes(1));
});
test("half a hand entered is still asked for", () => {
const state = atShowdown();
state.seats[1].cards = ["AS", ""];
assert.ok(queue(state).includes(1));
});
test("every hand entered means the sheet never opens", () => {
const state = atShowdown();
const hands = [["AS", "AH"], ["KS", "KH"], ["QS", "QH"], ["9H", "9D"]];
state.seats.forEach((player, i) => { player.cards = hands[i]; });
assert.deepStrictEqual(queue(state), []);
});
});
describe("in the order the cards come over", () => {
// The bar already says "Mike shows first — last to bet or raise" out loud. The
// sheet asking about somebody else first would be the tool contradicting itself
// in the same breath.
test("it starts with whoever the bar says shows first", () => {
const state = atShowdown();
state.streetAggressorIndex = 2;
assert.strictEqual(Betting.showdownOrder(state).index, 2);
assert.strictEqual(queue(state)[0], 2);
});
test("and then goes round the table from there", () => {
const state = atShowdown();
state.streetAggressorIndex = 2;
assert.deepStrictEqual(queue(state), [2, 3, 0, 1]);
});
test("nobody checked the river, so it starts left of the button", () => {
const state = atShowdown();
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
const first = Betting.showdownOrder(state);
assert.strictEqual(first.reason, "leftOfButton");
assert.strictEqual(queue(state)[0], first.index);
});
test("a player already entered drops out of the order without moving the rest", () => {
const state = atShowdown();
state.streetAggressorIndex = 2;
state.seats[3].cards = ["KS", "KH"];
assert.deepStrictEqual(queue(state), [2, 0, 1]);
});
test("everyone still in is asked exactly once", () => {
const state = atShowdown({ seats: 9 });
const asked = queue(state);
assert.strictEqual(new Set(asked).size, asked.length);
assert.strictEqual(asked.length, Betting.liveSeats(state).length);
});
});
+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);
});
});
+124
View File
@@ -0,0 +1,124 @@
// What the sidebar is a list of.
//
// The panels were Table Setup, Session, Chip Legend, Keyboard and Table Notes, and
// "Session" was a container word rather than a name. It held the standings, a waiting
// list, the hand log and the save/load/reset buttons — a topic, a task, a log and three
// utilities under one label, which is the "don't mix apples and oranges" failure: a
// navigation layer with more than one logic in it. Measured at 1206px and 103 visible
// elements, against 555 and 29 for the next biggest, and "The table itself" sat 1052px
// down inside it, so reaching Reset table meant opening a panel called Session and
// scrolling past the money and the log.
//
// This is a markup test because that is where the fault lived. Nothing was broken —
// every control worked and every id resolved — so nothing failed. What was wrong was
// which panel a thing was in and what that panel was called.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const PAGE = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
// A sidebar panel never contains another one — Table Setup's sub-panels carry a
// different class — so splitting on the opening tag is enough to say which panel any
// piece of markup is in, without parsing the document.
const panels = () => PAGE
.split(/<details class="sidebarPanel"/)
.slice(1)
.map(chunk => {
const name = chunk.match(/<summary>\s*(?:<h2>)?([^<]+)/);
return { name: name ? name[1].trim() : "(no summary)", markup: chunk };
});
const panelHolding = id => {
const found = panels().filter(p => p.markup.includes(`id="${id}"`));
assert.equal(found.length, 1, `${id} is in ${found.length} panels, expected exactly 1`);
return found[0].name;
};
// ---------------------------------------------------------------------------
describe("the sidebar's names", () => {
test("say what is inside them", () => {
// Every one of these is a thing a dealer goes looking for. None of them is a
// word that could hold anything — which is the test "Session" fails.
assert.deepEqual(panels().map(p => p.name), [
"Table Setup",
"Standings",
"Waiting list",
"Hands",
"Chip Legend",
"Keyboard",
"Table Notes",
"Save &amp; reset",
]);
});
test("and each one is a heading, not only a button", () => {
// A <summary> is announced as a button. The sidebar's names are the whole of its
// navigation, so without a heading inside, none of them is reachable by anyone
// moving through the page by heading — and splitting Session removed the four
// subheads that used to be the only headings down here.
panels().forEach(p => assert.match(p.markup, /<summary><h2>/,
`the ${p.name} panel's name is not a heading`));
});
});
describe("where things live", () => {
test("the money is together, and the warning about losing it is with it", () => {
assert.equal(panelHolding("standingsList"), "Standings");
assert.equal(panelHolding("settleUp"), "Standings");
assert.equal(panelHolding("bankReconcile"), "Standings");
// renderStorageWarning() says this "sits at the top of the panel a dealer opens
// to check the money". That panel is now called Standings.
assert.equal(panelHolding("storageWarning"), "Standings");
});
test("the queue for a seat is its own panel", () => {
assert.equal(panelHolding("waitingList"), "Waiting list");
assert.equal(panelHolding("waitingListForm"), "Waiting list");
});
test("the log and the two ways out of it are together", () => {
assert.equal(panelHolding("handHistoryList"), "Hands");
assert.equal(panelHolding("handHistoryFilter"), "Hands");
assert.equal(panelHolding("exportHistoryBtn"), "Hands");
assert.equal(panelHolding("clearHistoryBtn"), "Hands");
});
test("and the file is last, with the danger on the label", () => {
assert.equal(panelHolding("saveTableBtn"), "Save &amp; reset");
assert.equal(panelHolding("loadTableBtn"), "Save &amp; reset");
assert.equal(panelHolding("resetTableBtn"), "Save &amp; reset");
assert.equal(panels()[panels().length - 1].name, "Save &amp; reset",
"the panel that ends the night belongs at the end, away from the working ones");
});
});
describe("no panel goes back to being a grab-bag", () => {
// Not a size limit — Table Setup is legitimately long and Chip Legend legitimately
// repeats a row per chip. What this counts is *subjects*: a panel that needs its own
// headings to say what its parts are is a panel holding more than one thing, which
// is what Session was and how it got that way.
test("at most one subject needs a heading inside a panel", () => {
panels().forEach(p => {
const subheads = [...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
.map(m => m[1].trim());
assert.ok(subheads.length <= 1,
`${p.name} has ${subheads.length} sections inside it (${subheads.join(", ")}) — ` +
`if a panel needs headings to list its parts, its name is not doing the work`);
});
});
test("and the ones that remain are a detail of their panel, not a second subject", () => {
// Settle up is the standings once the night ends; Stacks in chips is the chip
// legend counted out. Both are the same subject at a different moment, which is
// the case a subhead is for.
const kept = panels().flatMap(p =>
[...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
.map(m => `${p.name}: ${m[1].trim()}`));
assert.deepEqual(kept, ["Standings: Settle up", "Chip Legend: Stacks in chips"]);
});
});
+106
View File
@@ -0,0 +1,106 @@
// The toast that outlived the thing it was talking about.
//
// Toasts are news about the last action, and they carry an Undo. Take another action
// and both of those go wrong at once: the sentence describes a step that is now two
// steps back, and the Undo behind it restores a snapshot from before everything since.
// "Next hand — button on seat 2. [Undo]" left up while a hand is dealt and three
// players call is an offer to move the button, wired to a button that deletes the hand.
//
// It is also simply in the way. The toast is a fixed strip near the bottom of the
// screen; on a 360x640 phone there is nowhere for it to sit that is clear of both the
// felt and the action bar, so a stale one covers seats and controls for eight seconds.
//
// So the bar drops it as it takes the next action. These tests hold that funnel shut:
// every action a dealer can take from the bar goes through `commit`, and `commit`
// clears the toast before it does anything else.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { ActionBar, Betting, makeTable } = require("./load");
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
const barSource = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
// A bar sat at a real table, with the toast replaced by something that keeps a log
// and the re-render stubbed out — none of these rules need the DOM back.
function onTable() {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
const log = [];
globalThis.Toast = {
onScreen: null,
show(message) { this.onScreen = message; log.push(`show ${message}`); },
dismiss() { this.onScreen = null; log.push("dismiss"); },
};
ActionBar.state = state;
ActionBar.render = () => {};
ActionBar.hooks = {
beginAction: label => log.push(`begin ${label}`),
save: () => log.push("save"),
rerender: () => log.push("rerender"),
};
return { bar: ActionBar, state, log };
}
test.afterEach(() => { delete globalThis.Toast; });
describe("a toast does not outlive its own news", () => {
test("the next action from the bar clears it", () => {
const { bar, state, log } = onTable();
Toast.show("Next hand — button on seat 2.");
bar.commit("dealing the hand", () => Betting.beginHand(state));
assert.equal(Toast.onScreen, null, "the hand is dealt; the sentence about seating the table is not news any more");
assert.ok(log.indexOf("dismiss") < log.indexOf("begin dealing the hand"),
`the toast goes before the action is recorded, so nothing shown during the action is swept up with it: ${log.join(", ")}`);
});
test("a toast raised by the action itself survives", () => {
const { bar, log } = onTable();
bar.commit("something that talks back", () => Toast.show("Bob is all in."));
assert.equal(Toast.onScreen, "Bob is all in.",
"commit clears what was there when it started, not what its own work puts up");
assert.deepEqual(log, ["dismiss", "begin something that talks back", "show Bob is all in.", "save", "rerender"]);
});
test("with no toast on screen it is still just one dismiss", () => {
const { bar, log } = onTable();
bar.commit("a fold", () => {});
assert.equal(log.filter(l => l === "dismiss").length, 1);
});
test("a page with no Toast at all does not throw", () => {
const { bar, state } = onTable();
delete globalThis.Toast;
assert.doesNotThrow(() => bar.commit("dealing the hand", () => Betting.beginHand(state)));
});
});
describe("every bar action really does go through that funnel", () => {
// The dismiss is worth nothing if some button reaches past `commit` to mutate the
// table itself. Actions are written as `this.commit(...)`; a `Betting.` call made
// straight from a click handler would be one that skips the clean-up — and the
// undo history with it, which is the older reason this funnel exists.
test("no click handler calls the betting engine directly", () => {
const strays = barSource
.split("\n")
.map((line, i) => [i + 1, line])
.filter(([, line]) => /=>\s*Betting\.(fold|check|call|allIn|raiseTo|beginHand|closeStreet)\(/.test(line))
.filter(([, line]) => !/this\.commit\(/.test(line))
.map(([n, line]) => `actionBar.js:${n}: ${line.trim()}`);
assert.deepEqual(strays, [], "these change the table without going through commit");
});
test("commit is what clears the toast, not each button", () => {
const dismissals = barSource.split("\n").filter(l => /Toast\.dismiss\(\)/.test(l)).length;
// Two: this one, and the raise pad — which opens without committing anything,
// and needs the strip clear because the pad makes the bar tall enough to push
// a toast up over the seats.
assert.equal(dismissals, 2,
"a third dismissal means a button is doing by hand what commit already does");
});
});
+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}`);
});
});
});
+95
View File
@@ -0,0 +1,95 @@
// Two players changing chairs.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
test("the whole seat moves", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].name = "Ann";
state.seats[0].cards = ["AS", "KD"];
state.seats[0].stack = 175;
state.seats[4].name = "";
HoldemState.swapSeats(state, 0, 4);
assert.equal(state.seats[4].name, "Ann");
assert.deepEqual(state.seats[4].cards, ["AS", "KD"]);
assert.equal(state.seats[4].stack, 175);
assert.equal(state.seats[0].name, "");
});
test("what they have in the pot goes with them", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
const posted = state.seats.map(p => p.committed);
const before = chipsOnTable(state);
HoldemState.swapSeats(state, 1, 4);
assert.equal(state.seats[4].committed, posted[1]);
assert.equal(state.seats[1].committed, posted[4]);
assert.equal(chipsOnTable(state), before, "a swap never creates or destroys a chip");
});
test("the button stays on the chair, not on the player", () => {
// A card room moves the button around the table, not around the people. Somebody
// who sits down in the big blind's chair is the big blind.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, dealerSeatIndex: 0 });
state.seats[0].name = "Ann";
HoldemState.swapSeats(state, 0, 2);
assert.equal(state.dealerSeatIndex, 0);
assert.notEqual(state.seats[0].name, "Ann");
});
test("the blinds follow the chairs too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
const before = HoldemState.getBlindSeats(state);
HoldemState.swapSeats(state, before.sbIndex, before.bbIndex);
const after = HoldemState.getBlindSeats(state);
assert.equal(after.sbIndex, before.sbIndex);
assert.equal(after.bbIndex, before.bbIndex);
});
test("swapping a seat with itself does nothing and says so", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 1, 1), false);
assert.equal(JSON.stringify(state), before);
});
test("a chair that isn't there is refused rather than swallowed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 0, 99), false);
assert.equal(HoldemState.swapSeats(state, -1, 0), false);
assert.equal(JSON.stringify(state), before);
});
test("swapping twice puts everything back", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
Betting.beginHand(state);
const before = JSON.stringify(state);
HoldemState.swapSeats(state, 1, 3);
HoldemState.swapSeats(state, 1, 3);
assert.equal(JSON.stringify(state), before);
});
test("moving into an empty chair leaves an empty chair behind", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[1].name = "Ben";
HoldemState.swapSeats(state, 1, 5);
assert.equal(state.seats[5].name, "Ben");
assert.equal(state.seats[5].inHand, true);
assert.equal(state.seats[1].inHand, false);
});
+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);
});
});
+37
View File
@@ -0,0 +1,37 @@
// How the think-clock reads.
const test = require("node:test");
const assert = require("node:assert");
const path = require("path");
const ActionBar = require(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"));
test("seconds under a minute", () => {
assert.equal(ActionBar.formatTank(0), "0:00");
assert.equal(ActionBar.formatTank(7), "0:07");
assert.equal(ActionBar.formatTank(59), "0:59");
});
test("minutes and seconds", () => {
assert.equal(ActionBar.formatTank(60), "1:00");
assert.equal(ActionBar.formatTank(241), "4:01");
assert.equal(ActionBar.formatTank(3599), "59:59");
});
test("past the hour it says the hour", () => {
// A table left open on a hand reaches this, and "65:03" is a number the eye reads
// as minutes and seconds when it isn't.
assert.equal(ActionBar.formatTank(3600), "1:00:00");
assert.equal(ActionBar.formatTank(3903), "1:05:03");
assert.equal(ActionBar.formatTank(45296), "12:34:56");
});
test("the minutes are padded once there are hours, and not before", () => {
// "1:5:03" would be wrong; "05:03" on its own would be odd.
assert.equal(ActionBar.formatTank(3903), "1:05:03");
assert.equal(ActionBar.formatTank(303), "5:03");
});
test("a day is still a readable number rather than a wrapped one", () => {
assert.equal(ActionBar.formatTank(90000), "25:00:00");
});
+175
View File
@@ -0,0 +1,175 @@
// What stands between the top of a phone and the felt.
//
// The header was 120px of a 390x844 phone — 16% of the window, spent before the table
// started — and only 46 of that was a control. The other 74 were a 22px page title with
// a gold rule under it, a back link, and a gold "Hand #1" pill: three things read once,
// above a screen whose subject is the green table underneath.
//
// A phone held sideways had already solved this. The landscape block clips the title and
// puts everything on one row, because at 390px tall there was no other option. Upright
// never got the same answer, and upright is how this tool is held: measured after, the
// header is 44px and the felt went from 496px to 570.
//
// Two claims are checked here. The stylesheet's, as text — the same way the seat sheet's
// and the reduced-motion rules are — because "which row is this on" is a layout decision
// that lives in the file. And the action bar's, by running it: the hand number had to go
// somewhere, and where it went is a rendering question.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { ActionBar, makeTable } = require("./load");
const PAGE = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// Every @media block written at exactly this condition, concatenated.
//
// All of them, not the first one — `@media (max-width: 700px)` is opened twice in this
// file, the second time to hold the phone layout proper, and a helper that stopped at
// the first found a block with none of these rules in it and reported them missing.
const mediaBlock = condition => {
const found = [];
const needle = "@media ";
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
const open = CSS.indexOf("{", at);
if (CSS.slice(at + needle.length, open).trim() !== condition) continue;
let depth = 0;
for (let i = open; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(open + 1, i)); break; }
}
}
assert.ok(found.length > 0, `no @media block written as "${condition}"`);
return found.join("\n");
};
const ruleIn = (block, selector) => {
const re = new RegExp(`(^|[},])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m");
const found = block.match(re);
return found ? found[2] : null;
};
const PHONE = () => mediaBlock("(max-width: 700px)");
// The header's sideways rules are in the plain landscape query, not the one that also
// caps the width — that later block is the seat sheet's and the action rail's. Both
// exist, and picking the wrong one finds a block with no header in it at all.
const LANDSCAPE = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
// ---------------------------------------------------------------------------
describe("the header is one row on a phone", () => {
test("upright gets the shape a phone on its side already had", () => {
// These four together are the whole change: don't wrap, drop the word beside the
// arrow, clip the title, and let the buttons size themselves in what is left.
const phone = PHONE();
assert.match(ruleIn(phone, "#holdemHeader") || "", /flex-wrap:\s*nowrap/,
"a wrapping header is a two-row header");
assert.match(ruleIn(phone, "#holdemHeaderLeft") || "", /display:\s*contents/,
"the arrow has to be a flex item of the header, not a block inside a block");
assert.ok(ruleIn(phone, ".backLinkWord"), "the word beside the arrow is not dropped");
assert.ok(ruleIn(phone, "#holdemHeader h1"), "the title is not addressed on a phone");
});
test("both narrow layouts clip the title rather than removing it", () => {
// `display: none` would take the page's only <h1> off the accessibility tree, and
// a page whose heading disappears below 700px is a page with no heading on the
// device it is mostly read on.
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
const rule = ruleIn(block, "#holdemHeader h1");
assert.ok(rule, `${which} does not address the title`);
assert.doesNotMatch(rule, /display:\s*none/,
`${which} removes the page's heading instead of hiding it`);
assert.match(rule, /clip-path:\s*inset\(50%\)/,
`${which} should clip the heading the way .visuallyHidden does`);
});
});
test("the actions row stops claiming a row of its own", () => {
// `width: 100%` is what made it a second row: a flex child at full width cannot
// share a line with anything, whatever the wrap setting says.
const rule = ruleIn(PHONE(), "#holdemHeaderActions") || "";
assert.doesNotMatch(rule, /width:\s*100%/);
assert.match(rule, /flex:\s*1/, "it should take the width the title gave up");
});
test("and the buttons grow into that width without ever shrinking below their labels", () => {
// `flex: 1 1 0` would divide the row into equal thirds and squeeze "Next Hand ⟳"
// — 105px of text — into 99. These are `white-space: nowrap`, so what gives is
// the text.
const rule = ruleIn(PHONE(), "#holdemHeaderActions .holdemBtn") || "";
assert.match(rule, /flex:\s*1\s+1\s+auto/, "an auto basis makes the label the floor");
assert.match(rule, /white-space:\s*nowrap/);
assert.match(rule, /min-height:\s*46px/, "across is the only direction that tightens");
});
test("the arrow keeps a thumb-sized box once it loses its word", () => {
// The word used to carry the width, and the padding only had to make up the
// height. With the word gone the arrow is the whole target, and the first
// attempt at 32px was measured by the layout sweep at 31x43 on every touch
// viewport it runs — 56 scenarios, one control.
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
const rule = ruleIn(block, ".holdemBackLink") || "";
assert.match(rule, /min-width:\s*44px/, `${which}'s back arrow is under the touch floor`);
// The negative margins that gave the hit area back to a two-row header would
// now pull the arrow out of a row it is the first item of.
assert.match(rule, /margin:\s*0/);
});
});
test("and it still says where it goes when the word is gone", () => {
// An "←" on its own is announced as "left arrow" and names nothing. The label
// contains the visible text on the sizes that still show it, which is what
// WCAG 2.5.3 asks of a name that says more than the label does.
const link = PAGE.match(/<a href="\/casino"[^>]*>/);
assert.ok(link, "the back link is gone");
const label = link[0].match(/aria-label="([^"]+)"/);
assert.ok(label, "the back link has no accessible name for when the word is hidden");
assert.match(label[1], /Casino/,
"the visible word has to appear in the spoken name");
});
});
describe("the hand number found somewhere to live", () => {
test("it is not a pill beside the page title any more", () => {
assert.ok(!PAGE.includes("handCounterBadge"),
"a counter read after the fact does not earn a line of a phone's header");
});
test("it is in the row that already says what hand you are in", () => {
// The street, the pot and the player count are the other three facts about the
// current hand, and they are drawn together. This one costs nothing there: the
// row was on the screen already.
const source = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"),
"utf8");
// Sliced from the top of renderStatus to the first mention of the street, so
// finding the hand chip inside that slice is the ordering claim as well as the
// presence one: it is appended before the street or it is not in here.
const status = source.slice(source.indexOf("renderStatus()"), source.indexOf("actionStreet"));
assert.match(status, /actionHand/,
"the status row does not say which hand this is, or says it after the street");
});
test("a table that has never dealt says #1, not #0 and not #undefined", () => {
const state = makeTable({ seatCount: 4 });
delete state.handNumber;
assert.equal(ActionBar.handLabel(state), "#1");
assert.equal(ActionBar.handLabel({ handNumber: 0 }), "#1");
assert.equal(ActionBar.handLabel({ handNumber: 12 }), "#12");
assert.equal(ActionBar.handLabel(null), "#1");
});
test("it is drawn quieter than the street beside it", () => {
// The street is read every deal; this is read when somebody asks about a hand
// that is already over. Same row, different job, and the colour says so.
const rule = CSS.match(/\.actionHand\s*\{([^}]*)\}/);
assert.ok(rule, ".actionHand has no rule");
assert.match(rule[1], /color:\s*var\(--text-dim\)/);
// A number that ticks over while the dealer is looking at it.
assert.match(rule[1], /font-variant-numeric:\s*tabular-nums/);
});
});
+500
View File
@@ -0,0 +1,500 @@
// A phone held sideways, and the three pieces of arithmetic that make the table fit on
// one.
//
// The layout audit is what proved this was broken and what proves it is fixed — it
// measures the drawn page, which is the only place a layout is really true. What is
// here is the part underneath that can be wrong on its own: which shape the bar is in,
// how much of the bottom that leaves spoken for, how a name is shortened when it will
// not fit, and how the ring is pulled back in when a puck lands past the rail.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { ActionBar, Render } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// ---------------------------------------------------------------------------
describe("which shape the bar is in", () => {
// The layout is chosen by a media query — orientation, height and width together.
// The one thing that must not happen is a second copy of that query in JavaScript,
// because the first thing anybody changes is the breakpoint, in one of the two
// places. So the stylesheet declares which layout it picked and actionBar.js reads
// the answer back.
const withComputed = (value, run) => {
const host = { id: "actionBar" };
global.getComputedStyle = el => ({
getPropertyValue: prop => (el === host && prop === "--actionBarLayout" ? value : ""),
});
try { return run(host); } finally { delete global.getComputedStyle; }
};
test("the stylesheet says, and this reads", () => {
assert.equal(withComputed(" rail ", host => ActionBar.isRail(host)), true);
assert.equal(withComputed(" bar ", host => ActionBar.isRail(host)), false);
});
test("anything it does not recognise is the bar it has always been", () => {
assert.equal(withComputed("", host => ActionBar.isRail(host)), false);
assert.equal(withComputed("sidebar", host => ActionBar.isRail(host)), false);
});
test("and with no browser at all, likewise", () => {
assert.equal(ActionBar.isRail(null), false);
assert.equal(ActionBar.isRail({ id: "actionBar" }), false,
"no getComputedStyle to ask — this runs in the unit suite too");
});
test("the stylesheet declares both halves of it", () => {
// If either of these goes, isRail() quietly answers "bar" forever and the felt
// is measured against a bar that is not underneath it.
assert.match(CSS, /#actionBar\s*\{[^}]*--actionBarLayout:\s*bar/,
"the default, on the base rule");
assert.match(CSS, /--actionBarLayout:\s*rail/,
"and the rail, inside the landscape query");
});
test("the rail is scoped to a phone, not to any short window", () => {
// Above 900px the sidebar is already a right-hand column and a rail would land
// on top of it.
const query = CSS.match(/@media \(orientation: landscape\)[^{]*\{[\s\S]{0,400}?--actionBarLayout: rail/);
assert.ok(query, "the rail lives in a landscape media query");
assert.match(query[0], /max-width:\s*900px/);
assert.match(query[0], /max-height:\s*560px/);
});
});
// ---------------------------------------------------------------------------
describe("the bar lines up with the table", () => {
// On a desktop the bar was centred on the window and the table on its own column,
// and the sidebar takes 320px out of the right of that — so the two things a dealer
// is looking at were 172px apart, at 960px wide and at 1600px and everywhere in
// between, because the offset is half the sidebar and the sidebar is a constant.
//
// The rule that fixes it is four numbers that have to keep agreeing with four other
// numbers, none of them anywhere near each other in the file. That is what this
// block is for: the arithmetic is checked on the running page by measurement (bar
// centre against table-column centre, which came out 0px off at every width), and
// the numbers it depends on are checked here so they cannot drift apart afterwards.
const rule = CSS.match(/@media \(min-width: 901px\) \{\s*#actionBar \{([^}]*)\}/);
test("there is a rule, and it is scoped above the one-column breakpoint", () => {
// Below 901px the sidebar is not a column at all — it is stacked under the table
// — so there is nothing to lean away from and the bar is centred as it was.
assert.ok(rule, "no min-width: 901px rule for #actionBar");
assert.match(CSS, /@media \(max-width: 900px\)[\s\S]{0,200}#holdemLayout \{\s*grid-template-columns: 1fr;/,
"the one-column layout should start exactly where this rule stops");
});
test("it leans away from the sidebar by the sidebar's own width", () => {
assert.match(rule[1], /left:\s*var\(--sp-6\)/);
assert.match(rule[1], /right:\s*calc\(var\(--sidebarW\)\s*\+\s*var\(--sp-5\)\s*\+\s*var\(--sp-6\)\)/);
});
test("and the sidebar's width is one number, not two", () => {
// The grid draws the column and the bar leans away from it. Written out twice,
// the day somebody widens the sidebar is the day the bar goes crooked and no
// test says so.
assert.match(CSS, /--sidebarW:\s*\d+px/, "the token should be declared");
assert.match(CSS, /grid-template-columns:\s*1fr var\(--sidebarW\)/,
"the grid should draw the column from the token");
});
test("no 100vw anywhere in it", () => {
// The bar's own centring comment warns about this: on a page with a scrollbar
// 100vw is wider than the viewport, and the overflow drags the table sideways.
// The offsets are exact without it — see the derivation in the stylesheet.
assert.equal(/100vw/.test(rule[1]), false, rule[1]);
});
});
// ---------------------------------------------------------------------------
describe("the button seat's name", () => {
// The dealer button's chip sits in the puck's top-right corner. Keeping the name out
// from under it with `padding-right` takes the chip's width off *every* line of the
// name, and the chip is on one of them: measured at 1280x900, the name box is 78px,
// "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56
// and broke as "Christop / her". The button moves every hand, so that is a player
// whose name reads properly for four hands and comes apart on the fifth.
const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/);
const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
test("a ring with room for two lines indents only the line beside the chip", () => {
assert.ok(roomy && float, "the roomy regime should use a float");
assert.match(float[1], /float:\s*right/);
assert.match(roomy[1], /padding-right:\s*0/);
});
test("the float is exactly one line tall", () => {
// Tall enough to cover the chip's reach into the name, and no taller: a float
// that spills into the second line indents that one too, which puts the whole
// problem back.
assert.match(float[1], /height:\s*1\.2em/);
});
test("break-word stands down where the float is doing the work", () => {
// It is what stops the float working — it breaks a word to fit the short line
// rather than letting it move past the float to the long one.
assert.match(roomy[1], /overflow-wrap:\s*normal/);
});
test("a crowded ring keeps the padding, because it has only one line", () => {
// `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has
// nowhere to move to — the only way to keep it off the chip is to shorten it.
// Trying the float on both cost a whole sweep to find out: the name dropped onto
// a second line that the chip's lower half was still sitting on, and the audit
// called it at every viewport in the list.
assert.ok(base, "the base rule should still reserve the chip's width");
assert.match(base[1], /padding-right:\s*22px/);
assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/);
});
test("the float is as wide as the chip it stands in for", () => {
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
const floatW = float[1].match(/width:\s*(\d+)px/);
assert.ok(chip && inset && floatW);
assert.equal(Number(floatW[1]), Number(chip[1]) + Number(inset[1]),
"the float should cover the chip and the gap it is inset by");
});
});
// ---------------------------------------------------------------------------
describe("how much of the bottom is spoken for", () => {
// feltRoomFor asks one question — what is under the felt — and gets two different
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
// the table hint and the two links, because the bar is off to the side.
const fakeSection = (wrapBottom, sectionBottom) => {
const wrap = {
getBoundingClientRect: () => ({ bottom: wrapBottom }),
parentElement: { getBoundingClientRect: () => ({ bottom: sectionBottom }) },
};
global.document = { getElementById: id => (id === "pokerTableWrap" ? wrap : null) };
};
test.afterEach(() => { delete global.document; });
test("it is the gap under the felt, links and all", () => {
fakeSection(300, 342);
assert.equal(ActionBar.roomBelowFelt(), 42);
});
test("a difference, not a height — so it does not chase the felt it is sizing", () => {
// The wrap's own height comes from --feltRoomPx, which is what this feeds. Read
// as "how tall is everything under the felt" off the section's height it would
// move every time the felt did, and settle nowhere.
fakeSection(300, 342);
const first = ActionBar.roomBelowFelt();
fakeSection(500, 542); // the felt grew by 200; what is under it did not
assert.equal(ActionBar.roomBelowFelt(), first);
});
test("nothing under the felt is nothing to reserve", () => {
fakeSection(300, 300);
assert.equal(ActionBar.roomBelowFelt(), 0);
});
test("and a section that measures shorter than its own child is not negative room", () => {
fakeSection(300, 288);
assert.equal(ActionBar.roomBelowFelt(), 0);
});
test("with no table on the page it asks for nothing", () => {
global.document = { getElementById: () => null };
assert.equal(ActionBar.roomBelowFelt(), 0);
});
test("the felt gets the height the bar used to take", () => {
// The whole point, in one line. At 640x360 the bar was 205px of a 360px window
// and the felt got 85. Beside the table instead, the same window gives the felt
// everything but the header and the links.
const asABar = ActionBar.feltRoomFor(360, 205, 70);
const asARail = ActionBar.feltRoomFor(360, 30, 70);
assert.ok(asARail > asABar * 2, `${asABar}px became ${asARail}px`);
});
});
// ---------------------------------------------------------------------------
describe("a name that will not fit", () => {
// Shortened at a word, not chopped in the middle of one. A puck is 34px of text on
// a nine-handed phone, and what the ellipsis produced for the four names on the test
// table was "Christo…", "Mike / from…", "Alexan…". Only one of those is throwing
// away something the dealer does not need: he is Mike, and the rest of it is how
// this table tells him from the other Mike.
//
// The label element is faked here rather than measured. What is under test is the
// ladder — which words go, in what order, and when it stops — and the browser's part
// is one boolean per pass.
const label = (text, fits) => {
const el = {
textContent: text,
title: "",
// The browser's answer, standing in for a real one: this name fits if
// `fits` says the version currently written to it does.
get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
get clientWidth() { return 10; },
get scrollHeight() { return 10; },
get clientHeight() { return 10; },
querySelector: () => el,
};
return el;
};
const layer = (labels) => ({
children: labels,
childElementCount: labels.length,
});
const seatsNamed = (...names) => {
Render.state = { seats: names.map(name => ({ name })) };
};
test.afterEach(() => { Render.state = null; });
test("trailing words go until it fits", () => {
const el = label("Christopher John Robinson", text => text === "Christopher John");
seatsNamed("Christopher John Robinson");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher John");
});
test("and it will go all the way to the first word", () => {
const el = label("Christopher John Robinson", text => text === "Christopher");
seatsNamed("Christopher John Robinson");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher");
});
test("a name never ends on a word a name cannot end on", () => {
// Dropping "work" off "Mike from work" leaves "Mike from", which is not a
// shorter name — it is a name that looks cut off, and it reads worse than the
// ellipsis this was meant to replace. He is Mike.
const el = label("Mike from work", () => false);
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike");
});
test("and it takes a run of them in one go", () => {
const el = label("Al with the hat", () => false);
seatsNamed("Al with the hat");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Al");
});
test("but a joining word inside a name is nobody's business", () => {
// The list is closed and small on purpose: it is not trying to understand names,
// only the handful of English joining words people label each other with at a
// home game. Anything it doesn't know stays, which is the safe way to be wrong —
// a name that keeps a word it could have dropped is merely long.
const el = label("Katarzyna Nowak Kowalska", text => text === "Katarzyna Nowak");
seatsNamed("Katarzyna Nowak Kowalska");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Katarzyna Nowak");
});
test("dropping never goes below the first word", () => {
// Every word after the first being a joining word is not a real name, but it is
// typeable, and it must not come out empty.
assert.equal(Render.dropAWord(["Mike", "from", "of", "the"], 4), 1);
assert.equal(Render.dropAWord(["from", "the"], 2), 1);
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
});
test("a single word is left to the ellipsis it always had", () => {
// There is no honest way to shorten "Christopher" that a dealer glancing across
// a table would read faster than "Christo…".
const el = label("Christopher", () => false);
seatsNamed("Christopher");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher", "never cut mid-word here");
});
test("a name that fits is not touched", () => {
const el = label("Mike from work", () => true);
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike from work");
});
test("the whole name is on the tooltip whether it was shortened or not", () => {
// A label clamped to two lines was already hiding some of it before any of this.
const shortened = label("Mike from work", text => text === "Mike");
const untouched = label("Bob", () => true);
seatsNamed("Mike from work", "Bob");
Render.fitSeatNames(layer([shortened, untouched]));
assert.equal(shortened.title, "Mike from work");
assert.equal(untouched.title, "Bob");
});
test("every seat is written to before any of them is measured", () => {
// Three passes over all the seats, not a loop per seat: each pass writes to
// every puck and then reads back from every puck, so the browser lays the felt
// out three times whatever the table's size. Interleaved, nine seats would cost
// nine layouts, and this runs on every render.
const reads = [];
const watched = (text, fits) => {
const el = label(text, fits);
Object.defineProperty(el, "scrollWidth", {
get() { reads.push(this.textContent); return fits(this.textContent) ? 10 : 100; },
});
return el;
};
const a = watched("Christopher John Robinson", t => t === "Christopher");
const b = watched("Katarzyna Nowak Kowalska", t => t === "Katarzyna");
seatsNamed("Christopher John Robinson", "Katarzyna Nowak Kowalska");
Render.fitSeatNames(layer([a, b]));
// Both names are read at each length before either is written down again.
assert.deepEqual(reads.slice(0, 2),
["Christopher John Robinson", "Katarzyna Nowak Kowalska"]);
assert.deepEqual(reads.slice(2, 4), ["Christopher John", "Katarzyna Nowak"]);
assert.equal(a.textContent, "Christopher");
assert.equal(b.textContent, "Katarzyna");
});
test("an empty chair has no name to shorten", () => {
const el = label("", () => false);
seatsNamed("");
assert.doesNotThrow(() => Render.fitSeatNames(layer([el])));
});
test("and with no layer there is nothing to do", () => {
assert.doesNotThrow(() => Render.fitSeatNames(null));
});
});
// ---------------------------------------------------------------------------
describe("pulling the ring back onto the table", () => {
// seatRingRadii works out the widest ring whose outermost puck still finishes inside
// the rail, and it is right about that — but it is right about a puck it measured
// before the ring moved, before the width search settled, and before a name was
// shortened or wrapped to a second line. Each of those changes a puck's height by up
// to 15px after the arithmetic is done.
//
// Measured on the running page, four of the thirteen viewports finished with the top
// seat 5-6px past the rail. Not a wrong formula: a stale input to a right one. So
// the ring is settled the way the audit judges it, by looking at where the pucks
// actually landed.
//
// The felt is 400x400 at (100, 100) throughout. Seats are placed by writing a
// percentage into style.left/top, which this reads back to work out where the puck
// would be — the same relationship the browser has, without a browser.
const felt = { left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400 };
const seat = (puckW, puckH, box) => ({
style: { left: "", top: "" },
classList: { toggle() {} },
getBoundingClientRect() {
// Whatever percentage was last written, resolved against the ring box.
const cx = box.left + (parseFloat(this.style.left) / 100) * box.width;
const cy = box.top + (parseFloat(this.style.top) / 100) * box.height;
return {
left: cx - puckW / 2, right: cx + puckW / 2,
top: cy - puckH / 2, bottom: cy + puckH / 2,
width: puckW, height: puckH,
};
},
});
// The ring box the percentages resolve against, deliberately the same as the felt so
// the arithmetic in the test stays readable.
const ringBox = { ...felt };
const setUp = (count, puckW, puckH) => {
const seats = Array.from({ length: count }, () => seat(puckW, puckH, ringBox));
Render.state = { seats: seats.map(() => ({ name: "" })), seatCount: count };
global.document = { getElementById: id => (id === "pokerTable"
? { getBoundingClientRect: () => felt }
: { getBoundingClientRect: () => ringBox }) };
// ringBox() reads #seatsLayer; the fake above hands the same box back for it.
return { children: seats, childElementCount: seats.length };
};
const place = (layer, radii) => {
for (let i = 0; i < layer.childElementCount; i++) {
Render.placeSeat(layer.children[i], i, 1, radii);
}
};
const worstOverhang = layer => {
let over = 0;
for (let i = 0; i < layer.childElementCount; i++) {
const b = layer.children[i].getBoundingClientRect();
over = Math.max(over, felt.left - b.left, b.right - felt.right,
felt.top - b.top, b.bottom - felt.bottom);
}
return over;
};
test.afterEach(() => { Render.state = null; delete global.document; });
test("a ring already inside the rail is left exactly where it is", () => {
const layer = setUp(6, 60, 50);
const radii = { rx: 30, ry: 30 };
place(layer, radii);
assert.deepEqual(Render.settleSeatsOnTable(layer, 1, radii), radii);
});
test("a puck past the rail brings the whole ring in", () => {
// 44% of 400 puts a seat's centre 176px from the middle; half a 60px puck is
// another 30, against a felt whose half-height is 200. Six pixels over, at the
// top and the bottom and both sides — which is the size the running page was
// actually wrong by.
const layer = setUp(4, 60, 60);
const radii = { rx: 44, ry: 44 };
place(layer, radii);
assert.equal(Math.round(worstOverhang(layer)), 6, "the fault is there to fix");
const settled = Render.settleSeatsOnTable(layer, 1, radii);
assert.ok(settled.ry < radii.ry, "the ring came in");
assert.ok(worstOverhang(layer) <= Render.SEAT_SETTLE_SLACK_PX,
`still ${worstOverhang(layer).toFixed(1)}px over`);
});
test("it comes in by what it was over, not by a step it guessed", () => {
const layer = setUp(4, 60, 60);
const radii = { rx: 44, ry: 44 };
place(layer, radii);
const over = worstOverhang(layer);
const settled = Render.settleSeatsOnTable(layer, 1, radii);
// Six pixels of a 400px felt is 1.5% of the ring, so 44 becomes 42.5 — the
// measurement, not a step size that would have to be tuned per screen.
assert.equal(settled.ry.toFixed(2), (44 - (over / felt.height) * 100).toFixed(2));
assert.equal(settled.ry, 42.5);
});
test("and it stops at the floor rather than collapsing into the pot", () => {
// A puck taller than the felt has an overhang no ring can fix. Pulling in
// forever would put every seat on top of the pot badge, which is a worse table
// than one with a puck touching the rail.
const layer = setUp(4, 60, 600);
const radii = { rx: 40, ry: 40 };
place(layer, radii);
const settled = Render.settleSeatsOnTable(layer, 1, radii);
assert.equal(settled.ry, Render.RING_MIN_R);
assert.ok(settled.ry > 0);
});
test("a table with nothing laid out yet is handed back untouched", () => {
Render.state = { seats: [], seatCount: 0 };
global.document = { getElementById: () => null };
const radii = { rx: 44, ry: 42 };
assert.deepEqual(Render.settleSeatsOnTable({ children: [], childElementCount: 0 }, 1, radii), radii);
});
test("it is bounded — this runs after every tap a dealer makes", () => {
assert.ok(Render.SEAT_SETTLE_PASSES <= 2,
"each pass is a forced layout of the whole felt");
assert.ok(Render.SEAT_NAME_PASSES <= 3);
});
});
+1045 -19
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
// Whether putting more in is called a bet or a raise.
//
// The arithmetic was always right — minRaiseTo returns two big blinds for the big
// blind's option and one for an opening bet. This is only what it's called, which for
// a tool a dealer announces the action from is not a small thing.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, makeTable } = require("./load");
// Walks the preflop action round to the big blind, who is owed nothing and gets to
// choose between checking and putting more in.
function toTheBigBlindsOption() {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
const bigBlind = state.seats.findIndex(player => Betting.committed(player) === state.bigBlind);
while (state.actingSeatIndex !== null && state.actingSeatIndex !== bigBlind) {
Betting.call(state, state.actingSeatIndex);
}
return { state, bigBlind };
}
test("the big blind's option is a raise, not a bet", () => {
const { state, bigBlind } = toTheBigBlindsOption();
assert.equal(state.actingSeatIndex, bigBlind);
assert.equal(Betting.toCall(state, state.seats[bigBlind]), 0, "they owe nothing");
assert.equal(Betting.wagerVerb(state), "raise", "but their own blind is a live bet");
});
test("and the minimum there is a real raise, not a call of the blind", () => {
const { state } = toTheBigBlindsOption();
assert.equal(Betting.minRaiseTo(state), state.bigBlind * 2);
});
test("facing the blinds preflop is a raise", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
Betting.beginHand(state);
assert.equal(Betting.wagerVerb(state), "raise");
});
test("first in on the flop is a bet", () => {
const { state, bigBlind } = toTheBigBlindsOption();
Betting.check(state, bigBlind);
Betting.closeStreet(state);
assert.equal(state.street, "flop");
assert.equal(Betting.wagerVerb(state), "bet");
assert.equal(Betting.minRaiseTo(state), state.bigBlind, "a first bet is one big blind");
});
test("after that bet it's a raise again", () => {
const { state, bigBlind } = toTheBigBlindsOption();
Betting.check(state, bigBlind);
Betting.closeStreet(state);
Betting.raiseTo(state, state.actingSeatIndex, 10);
assert.equal(Betting.wagerVerb(state), "raise");
});
test("and a bet again on the next street once it's checked around", () => {
const { state, bigBlind } = toTheBigBlindsOption();
Betting.check(state, bigBlind);
Betting.closeStreet(state);
while (state.actingSeatIndex !== null) Betting.check(state, state.actingSeatIndex);
Betting.closeStreet(state);
assert.equal(state.street, "turn");
assert.equal(Betting.wagerVerb(state), "bet");
});
test("a table playing without blinds opens with a bet", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, smallBlind: 0, bigBlind: 0 });
Betting.beginHand(state);
assert.equal(state.currentBet, 0);
assert.equal(Betting.wagerVerb(state), "bet");
});
test("asking doesn't change anything", () => {
const { state } = toTheBigBlindsOption();
const before = JSON.stringify(state);
Betting.wagerVerb(state);
assert.equal(JSON.stringify(state), before);
});
+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");
});
});
+276
View File
@@ -0,0 +1,276 @@
// When the tool asks the phone to stay awake, and — the part that actually matters —
// when it lets go again.
//
// A dealer's phone lies face-up on the felt all night. Left alone it dims and locks
// between the flop and the turn, every hand, and a dozen rounds of making the next step
// readable at a glance count for nothing behind a lock screen.
//
// The risk runs the other way too, and it is the one worth testing. A wake lock that is
// taken and not dropped is a phone that burns its battery through a break, a re-buy, and
// the twenty minutes everyone spends arguing about pizza — and the dealer has no way to
// tell that is what is happening. So the rule is narrow: held only while betting is
// actually open, and only while the dealer wants it.
//
// The browser side of this is a stand-in below rather than a real navigator, which lets
// the tests ask the questions a real table asks: what happens when the hand ends inside
// the request, what happens when the phone takes the lock back on its own, and what
// happens when the browser simply says no.
const test = require("node:test");
const { describe, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert");
const { WakeLock, HoldemState, makeTable } = require("./load");
// A stand-in for navigator.wakeLock that records what was asked of it. `deny` makes
// every request reject, the way a phone in battery saver does.
const fakeWakeLock = ({ deny = false } = {}) => {
const api = {
requests: 0,
released: 0,
live: [],
deny,
wakeLock: {
request: async () => {
api.requests += 1;
if (api.deny) throw new Error("denied");
const listeners = [];
const sentinel = {
released: false,
addEventListener: (name, fn) => { if (name === "release") listeners.push(fn); },
release: async () => {
if (sentinel.released) return;
sentinel.released = true;
api.released += 1;
listeners.forEach(fn => fn());
},
// What the browser does on its own when the tab stops being visible.
dropFromOutside: () => {
sentinel.released = true;
listeners.forEach(fn => fn());
},
};
api.live.push(sentinel);
return sentinel;
},
},
};
return api;
};
// Settles the promises `sync()` deliberately doesn't return — it is called from
// renderAll, which is synchronous, so the acquiring happens off to one side.
const settle = () => new Promise(resolve => setImmediate(resolve));
// Node has had its own `globalThis.navigator` since v21, defined as a getter — so a
// plain assignment to it does nothing at all and throws nothing either. Every test in
// here that thought it had installed a fake would have been quietly measuring Node's
// real navigator, which has no wakeLock, and passing for the wrong reason.
const setNavigator = value => {
Object.defineProperty(globalThis, "navigator", {
value, configurable: true, writable: true,
});
};
let realNavigator;
beforeEach(() => {
realNavigator = globalThis.navigator;
WakeLock.sentinel = null;
WakeLock.wanted = false;
WakeLock.pending = false;
WakeLock.told = true; // The toast is DOM; it is checked in the browser.
WakeLock.hooks = null;
});
afterEach(() => {
setNavigator(realNavigator);
});
const useFake = api => {
setNavigator(api);
return api;
};
// A table mid-hand, which is the only state that wants the screen.
const dealtIn = () => {
const state = makeTable({ seatCount: 4, stack: 200 });
state.bettingOpen = true;
return state;
};
describe("the rule about when a table needs the screen", () => {
test("a hand in progress does", () => {
assert.equal(WakeLock.shouldHold(dealtIn()), true);
});
test("a table between hands does not", () => {
const state = dealtIn();
state.bettingOpen = false;
assert.equal(WakeLock.shouldHold(state), false);
});
test("a dealer who turned it off does not, mid-hand or otherwise", () => {
const state = dealtIn();
state.keepScreenAwake = false;
assert.equal(WakeLock.shouldHold(state), false);
});
test("no table at all does not", () => {
assert.equal(WakeLock.shouldHold(null), false);
assert.equal(WakeLock.shouldHold(undefined), false);
});
test("a table that has never heard of the setting still does", () => {
// Every saved table from before this existed. The migration fills it in, but the
// rule itself has to read a missing value as yes or the first hand after an
// update runs without it.
const state = dealtIn();
delete state.keepScreenAwake;
assert.equal(WakeLock.shouldHold(state), true);
});
});
describe("taking the lock and giving it back", () => {
test("a hand starting takes one", async () => {
const api = useFake(fakeWakeLock());
WakeLock.sync(dealtIn());
await settle();
assert.equal(api.requests, 1);
assert.ok(WakeLock.sentinel);
});
test("the hand ending gives it back", async () => {
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
await settle();
state.bettingOpen = false;
WakeLock.sync(state);
await settle();
assert.equal(api.released, 1);
assert.equal(WakeLock.sentinel, null);
});
test("turning the setting off mid-hand gives it back", async () => {
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
await settle();
state.keepScreenAwake = false;
WakeLock.sync(state);
await settle();
assert.equal(api.released, 1);
});
test("a whole street of renders takes exactly one", async () => {
// sync() runs off renderAll, which fires many times a second while a street is
// being bet. Every one of those must be a no-op, or the tool leaks a sentinel
// per render and the screen stays lit long after the hand is over.
const api = useFake(fakeWakeLock());
const state = dealtIn();
for (let i = 0; i < 20; i += 1) {
WakeLock.sync(state);
await settle();
}
assert.equal(api.requests, 1);
assert.equal(api.released, 0);
});
test("two syncs in the same tick still take exactly one", async () => {
// The `pending` guard. Without it both calls get past the "already holding one?"
// check, and the second sentinel is never stored — so nothing can release it and
// the screen stays awake for the rest of the night.
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
WakeLock.sync(state);
await settle();
await settle();
assert.equal(api.requests, 1);
});
});
describe("the awkward moments", () => {
test("a hand that ends while the request is in flight leaves nothing held", async () => {
// A fold can end the hand between asking for the lock and being given it. The
// sentinel that arrives afterwards belongs to a hand that no longer exists.
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
state.bettingOpen = false;
WakeLock.sync(state);
await settle();
await settle();
assert.equal(WakeLock.sentinel, null, "nothing should still be held");
assert.equal(api.released, 1, "the one that arrived late should have been let go");
});
test("the browser taking it back leaves us able to take it again", async () => {
// What happens every time the dealer switches apps to check a rule. The browser
// releases the lock itself and never gives it back; if we go on believing we
// hold one, the screen sleeps for the rest of the night.
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
await settle();
api.live[0].dropFromOutside();
assert.equal(WakeLock.sentinel, null, "the release listener should have cleared it");
WakeLock.sync(state);
await settle();
assert.equal(api.requests, 2, "coming back to the tool should take a fresh one");
});
test("a browser that says no is not an error", async () => {
const api = useFake(fakeWakeLock({ deny: true }));
const state = dealtIn();
await assert.doesNotReject(async () => {
WakeLock.sync(state);
await settle();
});
assert.equal(api.requests, 1);
assert.equal(WakeLock.sentinel, null);
});
test("a browser without the API is left alone entirely", async () => {
useFake({});
assert.equal(WakeLock.supported(), false);
const state = dealtIn();
await assert.doesNotReject(async () => {
WakeLock.sync(state);
await settle();
});
assert.equal(WakeLock.wanted, false);
});
});
describe("what a saved table remembers about the screen", () => {
test("a new table keeps the screen on", () => {
assert.equal(HoldemState.createDefault().keepScreenAwake, true);
});
test("a table saved before the setting existed keeps the screen on", () => {
const saved = HoldemState.createDefault();
delete saved.keepScreenAwake;
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
});
test("a dealer who turned it off still has it off after a reload", () => {
const saved = HoldemState.createDefault();
saved.keepScreenAwake = false;
assert.equal(HoldemState.migrate(saved).keepScreenAwake, false);
});
test("junk in that field is read as on rather than trusted", () => {
const saved = HoldemState.createDefault();
saved.keepScreenAwake = "yes please";
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
});
});