Compare commits

...
Author SHA1 Message Date
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
26 changed files with 2651 additions and 89 deletions
+45 -9
View File
@@ -25,7 +25,6 @@
<section id="tableSection">
<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>
@@ -103,6 +102,7 @@
<details class="sidebarPanel">
<summary>Session</summary>
<div class="panelBody">
<p id="storageWarning" class="storageWarning" hidden></p>
<h3 class="panelSubhead">Standings</h3>
<div class="standingsTableWrap">
<div id="standingsTable">
@@ -127,6 +127,10 @@
<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>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
</div>
@@ -153,6 +157,20 @@
<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>Keyboard</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>
@@ -294,7 +312,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>
@@ -304,7 +324,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,
@@ -313,7 +333,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.
@@ -321,7 +341,7 @@
</p>
</section>
<section class="ruling">
<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
@@ -336,7 +356,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
@@ -346,7 +366,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
@@ -362,7 +382,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
@@ -373,7 +393,7 @@
</p>
</section>
<section class="ruling">
<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
@@ -383,6 +403,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.
+338 -23
View File
@@ -357,21 +357,18 @@ button,
justify-content: center;
}
#tableWatermark {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.06);
font-size: 2.4rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
text-align: center;
pointer-events: none;
user-select: none;
}
/* The felt used to carry a "TEXAS HOLD'EM" wordmark across its middle. It came out
from behind the pot badge in two pieces — measured at every seat count from two to
ten, the middle 20% of it was covered, so the felt read "TEXAS LD'EM" — and no
placement fixed that everywhere. Thirty per cent down cleared the badge on a desktop
and landed on the seats of a phone's square felt (43-70% covered); a smaller mark at
20% or 78% was clean on desktop and tablet and no better on a phone; the bottom one
fell off the oval entirely at 360px. A square felt with six to nine seats has no
clear band wide enough for a word.
So it's gone. A watermark that looks broken is worse than no watermark, and the felt
has a gradient, a rim, an inner gold line and a shadow to be a table with — plus a
header saying "Texas Hold'em Dealer" a few inches above it. */
#potBadge {
position: relative;
@@ -513,13 +510,53 @@ button,
/* Sitting out, not gone: still a person the dealer may need to deal back in, so
the name has to stay readable rather than fading almost to the felt. */
/* Out of the hand, still readable.
Both of these used to fade the whole seat — opacity 0.5 folded, 0.58 sitting out —
and opacity on a box fades the text you still have to read along with the box you
wanted to push back. Measured off the rendered pixels at 11.5px text, where AA asks
for 4.5:1:
live name 13.28 stack 4.75
folded name 4.75 stack 2.22
sat out name 7.00 stack 3.22
A folded player's stack is not decoration. It's what they'll have next hand and the
answer to "how much has Ben got?", and at 2.22:1 it was barely there.
So the box recedes and the text doesn't: a flatter, darker background and a muted
border say "not in this hand", while the name and the stack keep colours that
measure. The folded seat keeps its red border and the line through the name, which
is the part that actually names the state. */
.seat.seatInactive,
.seat.seatFolded {
background: linear-gradient(160deg, #232323, #171717);
border-color: rgba(255, 255, 255, 0.06);
}
/* A folded seat already says so twice — a red border and a line through the name — but
a seat that's sitting out said it only by being faint, and taking the faintness away
took the whole signal with it. A dashed edge is the same thing an empty chair wears,
which is right: neither is in this hand. The name and the stack tell them apart. */
.seat.seatInactive {
opacity: 0.58;
border-style: dashed;
border-color: rgba(255, 255, 255, 0.22);
}
.seat.seatInactive .seatPuckName,
.seat.seatFolded .seatPuckName {
color: #b0b0b0;
}
.seat.seatInactive .seatPuckStack,
.seat.seatFolded .seatPuckStack,
.seat.seatInactive .seatPuckTotal,
.seat.seatFolded .seatPuckTotal {
color: #8f8f8f;
}
.seat.seatFolded {
border-color: rgba(224, 123, 123, 0.4);
opacity: 0.5;
}
.seat.seatFolded .seatPuckName {
@@ -693,6 +730,18 @@ button,
display: none;
}
/* The dead button. It's on an empty chair on purpose, and that reads as a mistake —
a gold puck on nobody looks exactly like a button that has drifted and wants
dragging back. So it keeps the button's gold, to stay findable as the button, but
goes hollow and dashed: the same shape, visibly holding a place rather than
marking a player. The deal prompt says why in words; this is so the felt agrees
with it at a glance instead of contradicting it. */
.dealerChip.active.dealerChipDead {
background: rgba(245, 213, 71, 0.14);
border-style: dashed;
color: #f0d98c;
}
.blindBadge {
display: none;
position: absolute;
@@ -1013,9 +1062,24 @@ button,
text-align: center;
}
/* A bare checkbox ships at 13x13, which misses the 24px minimum with a mouse as well
as with a thumb. The row around it is the real target on touch, but the box itself
should be findable on any device. */
#dedicatedDealerToggle {
width: 24px;
height: 24px;
}
.linkBtn {
background: none;
border: none;
/* An underlined word was a 91x14 target, which misses the 24px minimum with a
mouse as well as with a thumb. The coarse-pointer block takes it to 44; this is
the floor everywhere else, and it changes nothing about how it looks. */
display: inline-flex;
align-items: center;
box-sizing: border-box;
min-height: 24px;
padding: 0;
color: #d8b46a;
font-family: inherit;
@@ -1080,6 +1144,128 @@ button,
line-height: 1.5;
}
/* Amber, like the bank check's disagreement — both are "your record of the night is
wrong and you can still do something about it". */
.storageWarning {
margin: 0;
padding: 0.5rem 0.6rem;
border: 1px solid rgba(240, 180, 106, 0.45);
border-radius: 7px;
background: rgba(240, 180, 106, 0.08);
color: #f0b46a;
font-size: 0.76rem;
line-height: 1.4;
}
/* Each stack and the chips that make it, laid out like the shortcut legend below —
the thing being named on one line, the answer under it, so a dealer with a rack in
one hand reads down rather than across. */
.colourUpList {
margin: 0.2rem 0 0;
}
.colourUpWho {
color: #ddd;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.colourUpChips {
margin: 0 0 0.45rem;
color: #9fb8a6;
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
}
.colourUpChips:last-child {
margin-bottom: 0;
}
/* The shortcut legend. A touch device has no keys to press, so it doesn't get a panel
telling it about them — the same pointer question the touch-target rules ask. */
@media (pointer: coarse) {
#keyboardPanel {
display: none;
}
}
.shortcutLegend {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.45rem 0.6rem;
margin: 0;
align-items: baseline;
}
.shortcutLegend dt {
display: flex;
gap: 0.2rem;
}
.shortcutLegend dd {
margin: 0;
color: #b8b8b8;
font-size: 0.78rem;
line-height: 1.35;
}
.shortcutKey {
background: #232323;
border: 1px solid #3f3f3f;
border-bottom-width: 2px;
border-radius: 4px;
padding: 0.05rem 0.32rem;
font-family: inherit;
font-size: 0.68rem;
color: #d8b46a;
white-space: nowrap;
}
/* One chip per ruling, wrapped across the top of the sheet. Names rather than icons:
a dealer scanning this is looking for the words somebody at the table just said. */
.rulingsIndex {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 1rem;
}
.rulingsIndexBtn {
box-sizing: border-box;
min-height: 32px;
padding: 0.3rem 0.6rem;
border-radius: 999px;
border: 1px solid #3a3a3a;
background: #232323;
color: #d8b46a;
font-family: inherit;
font-size: 0.72rem;
line-height: 1.25;
text-align: left;
cursor: pointer;
}
@media (hover: hover) {
.rulingsIndexBtn:hover {
background: #2e2e2e;
border-color: #4a4a4a;
}
}
/* A thumb needs the same 44px here as everywhere else. */
@media (pointer: coarse) {
.rulingsIndexBtn {
min-height: 44px;
font-size: 0.78rem;
}
}
/* The heading the index jumps to takes focus, and a focus ring on a heading that the
dealer just asked for reads as noise rather than as help. */
.ruling h3:focus {
outline: none;
}
.ruling {
margin-bottom: 1rem;
padding-left: 0.7rem;
@@ -1470,7 +1656,16 @@ button,
.sidebarPanel summary {
cursor: pointer;
padding: 0.7rem 0;
/* These headers are the sidebar's whole navigation and were 38px tall on every
device, phones included — the touch pass further down never reached them.
Padding rather than a flex box with min-height, because a <summary> only draws
its disclosure triangle while it is display: list-item. */
padding: 0.85rem 0;
/* A <summary> is content-box, so min-height would have been *added* to the padding
— measured at 71px before this line went in. Same trap the header buttons were
in, one file over. */
box-sizing: border-box;
min-height: 44px;
font-size: 0.9rem;
color: #eee;
letter-spacing: 0.03em;
@@ -2048,6 +2243,25 @@ body.hasActionBar #holdemSidebar {
font-variant-numeric: tabular-nums;
}
/* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */
.actionUndoKey {
/* See .actionKey below for why these are not fainter. */
margin-left: 0.4rem;
padding: 0 0.25rem;
border-radius: 4px;
border: 1px solid currentColor;
opacity: 0.75;
font-family: inherit;
font-size: 0.6rem;
line-height: 1.4;
text-transform: uppercase;
pointer-events: none;
}
/* A key hint is small — 9.6px — so AA asks 4.5:1 of it, and at the opacity these used
to carry they measured 2.97 to 3.33 against their buttons. That's the same fault as
the folded seats above: a fade chosen for looks pushing text a dealer is meant to
read under the threshold. They're still quieter than the label they sit beside. */
.actionKey {
position: absolute;
top: 5px;
@@ -2055,7 +2269,7 @@ body.hasActionBar #holdemSidebar {
padding: 0 0.25rem;
border-radius: 4px;
border: 1px solid currentColor;
opacity: 0.4;
opacity: 0.75;
font-family: inherit;
font-size: 0.6rem;
line-height: 1.4;
@@ -2579,6 +2793,16 @@ body.hasActionBar #toastHost {
color: #d6ffe0;
}
/* The chips to push, under the amount they add up to. Quieter than the payout it
belongs to — the dealer reads the name and the money first, then counts. */
.payoutChips {
display: block;
margin-top: 0.15rem;
font-size: 0.76rem;
color: #9fb8a6;
font-variant-numeric: tabular-nums;
}
.potWizardActions {
display: flex;
gap: 0.6rem;
@@ -2699,10 +2923,6 @@ body.hasActionBar #toastHost {
padding: 0.25rem 0.3rem;
}
#tableWatermark {
font-size: 1.4rem;
}
#potBadge {
padding: 0.5rem 1.1rem;
}
@@ -3185,3 +3405,98 @@ textarea:focus-visible {
font-size: 0.72rem;
color: #9a9a9a;
}
/* An aside that has something to press. It wraps rather than squeezing the button:
on a phone the sentence takes the width it needs and the button drops under it,
still at full tap size, instead of being crushed to a sliver at the end of a line. */
.dealPromptLine.dealPromptDebt {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.4rem 0.55rem;
}
.dealPromptDebtBtn {
/* Sized down from a full action button — it's an option beside a sentence, not
the thing the dealer came here to press. "Post blinds & deal" stays the button
that looks like the button. */
flex: 0 0 auto;
min-height: 32px;
padding: 0.24rem 0.7rem;
font-size: 0.72rem;
background: rgba(245, 213, 71, 0.1);
border: 1px solid rgba(245, 213, 71, 0.45);
color: #f0d98c;
}
@media (max-width: 700px), (pointer: coarse) {
/* A fingertip needs 44px, and this one moves money. */
.dealPromptDebtBtn {
min-height: 44px;
padding: 0.4rem 0.9rem;
}
}
/* ---------- Touch targets on a wide touch screen ----------
Every touch-target rule in this file sits behind `max-width: 700px`, and width was
never the question. A phone held sideways is 844px wide, so none of that work
applied to it. Measured on an emulated touch device at 844x390, with the sidebar
panels open: twelve controls under 44px, including the rulings link at 91x14 and
the dedicated-dealer checkbox at 13x13.
Restated here against the pointer instead of the viewport. 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. */
@media (pointer: coarse) {
.panelBody input:not([type="checkbox"]):not([type="color"]),
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
.panelBody select {
font-size: 16px;
min-height: 44px;
padding: 0.4rem 0.6rem;
}
.panelBody .holdemBtnSmall,
.handHistoryActions .holdemBtn,
.toastAction {
min-height: 44px;
}
/* Destructive and tiny is the worst combination: these delete a chip colour or a
custom field, and are 28px wide. */
.removeChipColorBtn,
.removeFieldBtn {
min-width: 44px;
min-height: 44px;
}
/* flex-shrink would take a stated width straight back off again. */
.chipColorInput {
flex: none;
width: 44px;
height: 44px;
}
/* A bare checkbox can't grow into a target, but the row it sits in can — the
label is what a tap actually lands on. */
.panelBody .fieldRow {
min-height: 44px;
}
#dedicatedDealerToggle {
width: 24px;
height: 24px;
}
/* An underlined word is a 14px-tall target; give it room without making it look
like a button. */
.linkBtn {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 0.4rem;
}
}
@@ -11,6 +11,11 @@ const ActionBar = {
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
// Whoever was already on the clock when this page loaded — see tankUnknownFor.
this.tankUnknownFor = state.bettingOpen &&
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
? state.actingSeatIndex
: null;
this.watchHeight();
this.render();
},
@@ -176,6 +181,17 @@ const ActionBar = {
undo.className = "actionUndo";
undo.title = `Undo ${undoLabel}`;
undo.textContent = `↶ ${undoLabel}`;
// U undoes, and nothing said so — this control was the only place a dealer
// could have learned it, and it was the one action bar control without its
// key printed on it.
const key = document.createElement("kbd");
// Its own class, not .actionKey: that one pins itself to the top-right
// corner of a big action button, and this control is a 30px pill with
// overflow hidden and no positioning context — the cap escaped it and
// floated above the bar. Here the key belongs inline, after the label.
key.className = "actionUndoKey";
key.textContent = this.KEY_HINTS.actionUndo;
undo.appendChild(key);
undo.addEventListener("click", () => this.hooks.undoLast());
row.appendChild(undo);
}
@@ -196,6 +212,14 @@ const ActionBar = {
tankStartedAt: null,
tankInterval: null,
// The seat that was already being asked to act when this page loaded, if there was
// one. Its clock is unknowable: the action moved to them before this session
// started — maybe two minutes ago, maybe last night — and starting from zero would
// have the bar say they had just begun thinking. Which it did, every time a dealer
// refreshed mid-hand. Cleared the moment the action moves, because from then on the
// clock is measuring something it actually saw.
tankUnknownFor: null,
// Restarts only when the action actually moves — a re-render for any other reason
// (a chip edit, a name change) must not put a thinking player back to zero.
syncTankTimer() {
@@ -209,6 +233,8 @@ const ActionBar = {
}
if (this.tankSeatIndex !== index) {
// Once the action has moved under our own eyes, nothing is unknown any more.
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
}
@@ -232,13 +258,33 @@ const ActionBar = {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—";
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
el.classList.remove("tankNudge", "tankSlow");
return;
}
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
const mins = Math.floor(seconds / 60);
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
el.title = "How long this player has been thinking";
el.textContent = this.formatTank(seconds);
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
},
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
// open on a hand does reach that, and sixty-five minutes written as though it were
// a minutes-and-seconds reading is a number the eye gets wrong.
formatTank(seconds) {
const pad = value => String(value).padStart(2, "0");
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = seconds % 60;
return hours > 0
? `${hours}:${pad(minutes)}:${pad(rest)}`
: `${minutes}:${pad(rest)}`;
},
// Shown only once someone is all-in and the money has actually split — a single
// undivided pot needs no announcement, and a line that's always there stops being
// read.
@@ -281,6 +327,8 @@ const ActionBar = {
actionAdvance: "↵",
raiseConfirm: "↵",
raiseCancel: "esc",
raiseAllIn: "A",
actionUndo: "U",
},
button(label, className, onClick, sublabel) {
@@ -293,7 +341,10 @@ const ActionBar = {
main.textContent = label;
btn.appendChild(main);
const hint = this.KEY_HINTS[className];
// Looked up by any of the button's classes, so a button can carry a shared
// class for its looks and a specific one for its key — the raise pad's four
// quick amounts are all raiseQuickBtn, but only All-in has a key.
const hint = className.split(/\s+/).map(name => this.KEY_HINTS[name]).find(Boolean);
if (hint) {
const kbd = document.createElement("kbd");
kbd.className = "actionKey";
@@ -397,7 +448,7 @@ const ActionBar = {
];
options.forEach(option => {
const value = Math.max(minTo, Math.min(option.value, maxTo));
const btn = this.button(option.label, "raiseQuickBtn", () => {
const btn = this.button(option.label, `raiseQuickBtn${option.label === "All-in" ? " raiseAllIn" : ""}`, () => {
this.raiseDraft = value;
this.render();
}, Money.format(value));
@@ -550,12 +601,27 @@ const ActionBar = {
return wrap;
}
line.textContent =
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
// the small blind" out loud is the whole point — a dealer who reads a preview
// with a blind missing and isn't told why will assume the tool has lost track.
line.textContent = preview.deadSmallBlind
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
wrap.appendChild(line);
// Why a position is missing. Both of these are the dead button rule doing its
// job, and both look like a bug until somebody says otherwise.
if (preview.deadButton || preview.deadSmallBlind) {
const dead = document.createElement("p");
dead.className = "dealPromptLine dealPromptAside";
dead.textContent = preview.deadButton
? `Dead button — seat ${preview.buttonIndex + 1} is empty. Its player left out of the blinds, so the button waits a hand rather than moving the big blind on twice.`
: `Dead small blind — the player due to post it left the table. The big blind still moves on one player, so nobody pays two in an orbit.`;
wrap.appendChild(dead);
}
// Dealing relocates a button sitting on an empty chair. That has to be said
// before it happens rather than discovered afterwards — a fresh table starts
// the button on seat 1 whether or not anyone is sitting there.
@@ -577,6 +643,44 @@ const ActionBar = {
wrap.appendChild(out);
}
// Somebody who has just sat down between the button and the big blind has had
// the blinds go past them, and dealing them in there is a free hand in late
// position. At a table the answer is that they post the big blind to come in
// now, or wait for it to reach them. The tool used to do neither and say
// nothing: it dealt them in for free, and the dealer had no way to know it had
// happened. Both answers are offered here; neither is forced, because a home
// game that doesn't care shouldn't have to argue with its own scoreboard.
// One line for however many have walked up, not one each. The bar is docked
// over the felt and the felt has already shrunk as far as it will go on a
// phone, so every line here is taken out of the ring of seats: measured at
// 390x844 with two newcomers, a line apiece put the bar over two fifths of the
// bottom seat. They post together or they wait together, which is what happens
// at a table anyway when two people sit down at once.
if (preview.debtors.length > 0) {
const ids = preview.debtors.map(i => this.state.seats[i].id);
const posting = ids.every(id => preview.posting.includes(id));
const who = listNames(preview.debtors);
const each = preview.debtors.length > 1 ? " each" : "";
const line = document.createElement("p");
line.className = "dealPromptLine dealPromptAside dealPromptDebt";
const text = document.createElement("span");
text.textContent = posting
? `${who} posting ${Money.format(preview.bigBlind)}${each} to come in.`
: `${who} sat down after the blinds — ${preview.debtors.length > 1 ? "free hands" : "a free hand"} unless they post.`;
line.appendChild(text);
line.appendChild(this.button(
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
"dealPromptDebtBtn",
() => this.commit(
posting ? `${who} waiting for the blind` : `${who} posting to come in`,
() => { this.state.postingIn = posting ? [] : ids; })
));
wrap.appendChild(line);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
@@ -78,10 +78,16 @@ const Betting = {
: { ...dealt, dealerSeatIndex: buttonIndex };
const satOut = this.brokeSeats(state);
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
if (sbIndex === null || bbIndex === null) return null;
const { sbIndex, bbIndex, headsUp, manual, deadSmallBlind } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return null;
return {
sbIndex, bbIndex, headsUp, manual: !!manual,
sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind,
deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand,
// Asked of the table as it will be dealt, for the same reason the blinds
// are: someone sitting out for having no chips isn't owed a blind, and the
// big blind of the hand about to go out isn't either.
debtors: HoldemState.blindDebtors(asDealt, bbIndex),
posting: (state.postingIn || []).slice(),
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
satOut,
@@ -448,16 +454,46 @@ 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;
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])));
// 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);
state.currentBet = this.round(Math.max(
sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
// Heads-up the button is the small blind and acts first before the flop; with
@@ -19,7 +19,7 @@ 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 = []) {
toCsv(handHistory, standings = [], { notes = "" } = {}) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
@@ -41,7 +41,12 @@ 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,
@@ -51,6 +56,7 @@ const HandHistoryExport = {
Money.forSheet(row.boughtIn || 0),
Money.forSheet(row.chipsNow || 0),
Money.forSheet(row.cashPosition || 0),
row.notes || "",
]);
});
@@ -76,6 +82,17 @@ const HandHistoryExport = {
}
}
// 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");
},
@@ -86,8 +103,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");
+156 -8
View File
@@ -96,6 +96,19 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
// 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 => {
// 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
@@ -108,6 +121,11 @@ document.addEventListener("DOMContentLoaded", () => {
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
: `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
// Putting the button somewhere by hand is the dealer overruling the
// rotation, dead positions included. Leaving them set would hold a
// small blind dead at a table whose button has moved out from under it.
state.deadButton = false;
state.deadSmallBlind = false;
if (willRepost) Betting.repostBlinds(state);
});
},
@@ -363,17 +381,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);
});
});
@@ -394,6 +425,29 @@ 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);
});
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
[
@@ -422,7 +476,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", () => {
section.scrollIntoView({ block: "start", behavior: "smooth" });
// 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();
@@ -461,6 +598,17 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
{ notes: state.notes });
});
// 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 });
}
});
@@ -48,6 +48,76 @@ 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.
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";
},
// "+$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) {
@@ -246,10 +246,23 @@ const PotWizard = {
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()) {
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);
@@ -49,11 +49,21 @@ const Render = {
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
},
// Whether this device can actually drag a seat. HTML5 drag events are never
// generated from a touch screen, so on a phone the last clause of the hint below
// described something impossible — on the device the rest of the sentence was
// written for. Touch gets told about the menu route instead, which works.
canDragSeats() {
return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches;
},
renderTableHint() {
const hint = document.getElementById("tableHint");
// Gestures are invisible until someone tells you about them, and this is the
// only place on the table with room to say so.
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
hint.textContent = this.canDragSeats()
? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."
: "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat.";
},
renderNotes() {
@@ -163,6 +173,12 @@ const Render = {
layer.innerHTML = "";
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
// fixing. Once it's the dead button it means the opposite — it's exactly where
// it belongs — so it gets marked rather than looking like the thing to correct.
const deadButtonHere = this.state.deadButton &&
HoldemState.deadPositionsApply(this.state) &&
!this.state.seats[dealerIndex].inHand;
this.state.seats.forEach((player, index) => {
const node = template.content.firstElementChild.cloneNode(true);
@@ -191,8 +207,11 @@ const Render = {
// of nine identical buttons and couldn't tell which seat held the real one.
// The label now says whose it is, or what pressing it would do.
const who = (player.name || "").trim() || `seat ${index + 1}`;
dealerChip.classList.toggle("dealerChipDead", hasButton && deadButtonHere);
const chipLabel = hasButton
? `${who} has the dealer button`
? (deadButtonHere
? `Dead button on empty seat ${index + 1} — its player left out of the blinds`
: `${who} has the dealer button`)
: `Give the dealer button to ${who}`;
dealerChip.setAttribute("aria-label", chipLabel);
dealerChip.title = chipLabel;
@@ -657,6 +676,49 @@ const Render = {
list.appendChild(row);
});
this.renderColourUp();
},
// Each stack as the chips that make it.
//
// The legend has only ever read chips *into* an amount — counting what's in front
// of somebody. Colouring up at the end of a level is the same question backwards,
// and it's the one the dealer has to answer with a rack in one hand: $205 is four
// fifties and a five. So is counting a stack out for a player who's leaving.
//
// Only when there's a legend to work from and a stack to break down; a table that
// never set its chip colours up gets nothing, which is what it had before.
renderColourUp() {
const host = document.getElementById("colourUp");
const list = document.getElementById("colourUpList");
if (!host || !list) return;
const legend = this.state.chipLegend || [];
const rows = this.state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0)
.map(({ player, index }) => ({
name: HoldemState.displayName(player, index),
stack: Betting.round(Number(player.stack) || 0),
chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend),
}))
.filter(row => row.chips);
host.hidden = rows.length === 0;
if (host.hidden) return;
list.innerHTML = "";
rows.forEach(row => {
const term = document.createElement("dt");
term.className = "colourUpWho";
term.textContent = `${row.name} — ${Money.format(row.stack)}`;
const detail = document.createElement("dd");
detail.className = "colourUpChips";
detail.textContent = row.chips;
list.appendChild(term);
list.appendChild(detail);
});
},
// ---- Pot breakdown ----
@@ -741,6 +803,41 @@ const Render = {
this.renderBankReconciliation();
this.renderSettleUp(standings);
this.renderStorageWarning();
},
// A toast is a moment; this table not being saved is a fact about the rest of the
// night. It sits at the top of the panel a dealer opens to check the money, and it
// stays there, because the raise pad dismisses toasts and the one time this warning
// matters is the one time it must not have been missed.
renderStorageWarning() {
const host = document.getElementById("storageWarning");
if (!host) return;
host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive;
if (host.hidden) return;
// Any of these can be true at once — a browser having trouble with storage
// tends to have it in both directions — and the one that explains an empty
// table goes first, because that's the question the dealer is standing there
// asking.
const lines = [];
if (HoldemState.otherTabActive) {
lines.push(
"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.");
}
if (HoldemState.loadFailed) {
lines.push(
"The saved table couldn't be read, so this one started empty. " +
"The unreadable copy has been kept rather than written over.");
}
if (HoldemState.saveFailed) {
lines.push(
"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.");
}
host.textContent = lines.join(" ");
},
// The last step the standings stopped short of. Six signed numbers at 2am is not
@@ -788,8 +885,13 @@ const Render = {
note.textContent =
`${Money.format(plan.shortBy)} 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.`;
} else {
note.textContent =
`${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
// If a pot was thrown away on purpose, that's where the gap went, and the
// dealer already answered a dialog about it. Telling them to go and fix it
// would be sending them after their own decision.
const discarded = Math.max(0, Number(this.state.discarded) || 0);
note.textContent = discarded > 0
? `${Money.format(plan.overBy)} is owed with nobody to pay it to. ${Money.format(discarded)} was taken off the table when a hand was abandoned, which accounts for ${discarded >= plan.overBy ? "it" : "some of it"}.`
: `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
}
note.classList.toggle("settleUpOff", !plan.balanced);
},
@@ -808,9 +910,22 @@ const Render = {
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
// An abandoned pot the dealer confirmed throwing away is money that left the
// table on purpose. It gets its own line so the two columns still add up in
// front of them, rather than looking like a shortfall they have to explain.
const discardedRow = document.getElementById("bankDiscardedRow");
if (discardedRow) {
discardedRow.hidden = !(bank.discarded > 0);
if (bank.discarded > 0) {
document.getElementById("bankDiscarded").textContent = Money.format(bank.discarded);
}
}
const verdict = document.getElementById("bankReconcileVerdict");
if (bank.balanced) {
verdict.textContent = "Balanced — the cash covers every chip on the table.";
verdict.textContent = bank.discarded > 0
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
@@ -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;
},
@@ -63,29 +69,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();
});
@@ -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));
+249 -9
View File
@@ -51,6 +51,9 @@ const HoldemState = {
notes: "",
handNumber: 1,
handHistory: [],
// Chips taken off the table on purpose, so the bank check can tell them
// apart from chips that have gone missing.
discarded: 0,
smallBlind: 1,
bigBlind: 2,
defaultStack: null,
@@ -78,27 +81,115 @@ const HoldemState = {
// button order is the wrong answer often enough that it has to be
// overridable. Cleared at the start of every hand.
blindOverride: null,
// Who actually posted, the last time cards went out — { sbIndex, bbIndex }.
// The next hand's rotation is worked out from this rather than from the
// seating as it stands, because by then the people it needs to know about
// are exactly the ones who have got up and gone.
postedBlinds: null,
// Player ids that were dealt in the last hand actually dealt. What makes a
// seat recognisable as having *just* joined — new to the table, or back
// from sitting out — which is the whole question behind owing a blind.
dealtLastHand: [],
// Ids the dealer has said will buy in past the blinds this hand, by posting
// the big blind rather than waiting for it. Cleared every hand: it's a
// decision about one deal, not a standing arrangement.
postingIn: [],
// Set when somebody leaves out of the blinds and the rotation has to hold
// to the seats to keep the big blind moving one player at a time. The
// button then sits on the chair they vacated, and no small blind is posted.
deadButton: false,
deadSmallBlind: false,
};
},
// Where an unreadable save is kept instead of being written over.
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
// True once another tab has written to this table. Two tabs each hold their own
// copy in memory and each save writes the whole thing, so the second one to act
// silently throws the first one's work away — measured: tab A deals and takes a
// call, tab B renames a seat, and the deal, the blinds and the call are gone with
// nothing said in either tab.
//
// Watched rather than merged. Replacing the table under a dealer's hands mid-hand
// would be its own hazard; knowing there are two of them is what they need.
otherTabActive: false,
onOtherTab: null,
// The browser fires this in every *other* tab, never the one that wrote, so our
// own saves can't trip it.
watchOtherTabs() {
if (typeof window === "undefined" || this.tabWatcher) return;
this.tabWatcher = event => {
if (event.key !== this.STORAGE_KEY) return;
if (this.otherTabActive) return;
this.otherTabActive = true;
if (typeof this.onOtherTab === "function") this.onOtherTab();
};
window.addEventListener("storage", this.tabWatcher);
},
// True when there was a saved table and it couldn't be read. Same pair as
// saveFailed above, for the same reason: the UI has to be able to say so.
loadFailed: false,
onLoadFailed: null,
// A saved table that won't parse becomes an empty table, which is the only thing
// it can become — but it used to become one silently, and then the first edit
// saved over the top of it. A dealer mid-session saw an empty felt with no idea
// whether they'd mis-tapped Reset or the browser had lost the night, and whatever
// a person might have picked out of the raw JSON was gone a tap later.
//
// So the unreadable value is moved aside first. It is not offered as a restore —
// half a JSON document isn't a table, and pretending otherwise would be worse than
// an empty one — but it stops being destroyed, and somebody who wants the
// standings out of it still can.
failedLoad(raw) {
if (!this.loadFailed) {
this.loadFailed = true;
try {
if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
} catch {
// No room to keep it. Nothing more to try, and the warning below is
// the part that actually matters.
}
if (typeof this.onLoadFailed === "function") this.onLoadFailed();
}
return this.createDefault();
},
load() {
let raw = null;
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
// Tables saved before live betting existed are missing its fields; fill
// them in rather than making the dealer reset a table mid-session.
const defaults = this.createDefault();
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
"actingSeatIndex", "blindOverride"]
"actingSeatIndex", "blindOverride", "postedBlinds"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
// A table saved before the dead button existed has neither flag. False is
// the right guess for both: it means the rotation carries on the ordinary
// way, which is what that table was already doing.
parsed.deadButton = parsed.deadButton === true;
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
// An old save has no record of who was dealt last hand. Empty is the
// honest answer — it reads as "this table hasn't played yet", so the first
// hand after reopening treats nobody as having just walked up, rather than
// asking the whole table to post.
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
// progress. Anything already committed, or anyone already on the clock,
@@ -118,16 +209,37 @@ const HoldemState = {
return parsed;
} catch {
return this.createDefault();
// Malformed JSON — a truncated write, another tab, a browser hiccup.
return this.failedLoad(raw);
}
},
// True once a save has failed. The UI reads this to keep saying so, because a
// toast is a moment and this is a fact about the rest of the night.
saveFailed: false,
// Called the first time a save fails. Set by the UI.
onSaveFailed: null,
save(state) {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage full/unavailable (private browsing, etc.) — the table still works
// for the rest of the session, it just won't survive a refresh.
return true;
} catch (error) {
// Storage full, or private browsing. The table still works for the rest of
// the session — it just won't survive a refresh, and nothing used to say
// so. That is the same shape of fault the bank check exists for: silent
// until it is too late to do anything about it. A dealer who finds out by
// refreshing has lost the night's standings, and the only warning they got
// was the table being empty.
//
// Announced once. A dealer tapping a bar thirty times a hand does not need
// thirty of these.
if (!this.saveFailed) {
this.saveFailed = true;
if (typeof this.onSaveFailed === "function") this.onSaveFailed(error);
}
return false;
}
},
@@ -245,6 +357,10 @@ const HoldemState = {
// sits on nobody most hands — and that isn't only untidy: position and blinds are
// supposed to come round to each player at the same rate, and they don't if the
// button is rotating past chairs nobody is sitting in.
//
// The fallback, now. nextButtonRotation is what a hand that was actually dealt
// rotates by; this is for the cases it has nothing to work from — a fresh table,
// a hand never dealt, a dealer pressing Next Hand twice to pass the button on.
advanceButton(state) {
for (let step = 1; step <= state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
@@ -283,6 +399,10 @@ const HoldemState = {
buttonSeatForDeal(state) {
const seated = state.seats[state.dealerSeatIndex];
if (seated && seated.inHand) return state.dealerSeatIndex;
// A button left on an empty chair on purpose is the dead button, and moving it
// to a player is precisely what it's there to stop. Only a button that has been
// stranded by accident gets rescued.
if (state.deadButton && this.deadPositionsApply(state)) return state.dealerSeatIndex;
for (let step = 0; step < state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) return index;
@@ -305,6 +425,13 @@ const HoldemState = {
return o;
},
// A dead button and a dead small blind are three-handed-and-up ideas. Down to two
// players the button *is* the small blind and the blinds simply alternate, so
// whatever the last rotation left behind stops applying.
deadPositionsApply(state) {
return state.seats.filter(p => p.inHand).length >= 3;
},
// Who posts what. Heads-up is its own rule: with two players the button posts the
// small blind, which is the opposite of the usual order.
getBlindSeats(state) {
@@ -336,11 +463,88 @@ const HoldemState = {
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
}
// The small blind is dead: whoever would have moved into it has left the table,
// and the seat stays empty for a hand rather than dragging the big blind
// forward an extra place. One player gets a free hand out of position for it;
// that's the price of nobody being charged two big blinds in an orbit.
if (state.deadSmallBlind && this.deadPositionsApply(state)) {
return { dealerIndex, sbIndex: null, bbIndex: live[0], headsUp: false, deadSmallBlind: true };
}
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
},
// Seats about to be dealt a hand they haven't paid for.
//
// Somebody who walks up and sits down between the button and the big blind has had
// the blinds go past them. Dealing them in there is a free hand in late position —
// which is exactly what posting is for, and it comes out of everybody else's night
// a couple of dollars at a time. At a real table they either post the big blind to
// come straight in, or they wait for it to reach them.
//
// The seat that *is* the big blind this hand isn't owed anything: they're paying.
// Neither is anyone at a table that hasn't dealt a hand yet, where everybody is new
// and nobody has been skipped.
blindDebtors(state, bbIndex) {
const played = Array.isArray(state.dealtLastHand) && state.dealtLastHand.length > 0;
if (!played) return [];
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player, index }) =>
player.inHand &&
index !== bbIndex &&
!state.dealtLastHand.includes(player.id))
.map(({ index }) => index);
},
// Where the button goes for the next hand, and whether anything is left dead.
//
// Ordinarily it follows the small blind round — the next player along, which is
// what it has always done. The case that needs care is somebody getting up out of
// the blinds. Passing the button to the next *player* then pulls the whole blind
// order forward a place: the player after the empty chair is never asked for a big
// blind at all, and the one after them is asked an orbit early. Holding the
// rotation to the seats instead is the dead button, and getting it wrong costs a
// real player a real big blind.
//
// Worked out from who actually posted last hand, not from the seating as it stands,
// because by now the people it needs to know about are the ones who have gone.
nextButtonRotation(state) {
const posted = state.postedBlinds;
const inRange = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i];
const here = i => inRange(i) && state.seats[i].inHand;
// Nothing to hold to: a fresh table, a hand that was never dealt, or one where
// the blinds were named by hand. And heads-up has its own rule — with two
// players the button is the small blind, so there is no position to leave dead.
if (!posted || !inRange(posted.sbIndex) || !inRange(posted.bbIndex) || !this.deadPositionsApply(state)) {
return { buttonIndex: null, deadButton: false, deadSmallBlind: false };
}
// Both still sitting there is the ordinary case, and this comes out as the next
// player along — the same answer as passing the button, arrived at from the
// blinds so that the unusual case doesn't need a separate path.
return {
buttonIndex: posted.sbIndex,
deadButton: !here(posted.sbIndex),
deadSmallBlind: !here(posted.bbIndex),
};
},
startNextHand(state) {
this.advanceButton(state);
const rotation = this.nextButtonRotation(state);
if (rotation.buttonIndex === null) {
this.advanceButton(state);
state.deadButton = false;
state.deadSmallBlind = false;
} else {
state.dealerSeatIndex = rotation.buttonIndex;
state.deadButton = rotation.deadButton;
state.deadSmallBlind = rotation.deadSmallBlind;
}
// Spent. Pressing Next Hand again without dealing is a dealer passing the
// button along, not a second rotation off the same hand.
state.postedBlinds = null;
// Cards aren't out and nothing is posted until the dealer says so.
state.bettingOpen = false;
state.actingSeatIndex = null;
@@ -351,6 +555,9 @@ const HoldemState = {
// the next one would freeze the blinds on two players for the rest of the
// night, which is the opposite of what naming them once is for.
state.blindOverride = null;
// Posting to come in is a decision about one deal. Carrying it forward would
// charge somebody an extra big blind every hand for the rest of the night.
state.postingIn = [];
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
@@ -454,6 +661,14 @@ const HoldemState = {
// window is almost always.
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
// Whatever the dealer wrote against this seat — "IOU: $20 to Ann",
// "Venmo: @ann". It's carried here so the export can hand it over with
// the money it belongs to; the standings panel itself doesn't show it.
const notes = (seat && Array.isArray(seat.customFields) ? seat.customFields : [])
.filter(field => (field.label || "").trim() || (field.value || "").trim())
.map(field => `${(field.label || "").trim()}: ${(field.value || "").trim()}`.replace(/^: | :$/, ""))
.join("; ");
return {
...t,
in: Money.round(t.in),
@@ -462,11 +677,27 @@ const HoldemState = {
boughtIn,
chipsNow,
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
notes,
};
})
.sort((a, b) => b.net - a.net);
},
// Two players change chairs.
//
// The whole seat moves — name, stack, cards, what they have in the pot — because
// that is what changing chairs is. What stays behind is everything that belongs to
// the *position*: the button, the blinds and the acting index all name a chair, and
// a player who moves into the big blind's chair is the big blind. That is the same
// answer a card room gives.
swapSeats(state, a, b) {
const seats = state.seats || [];
if (a === b) return false;
if (!seats[a] || !seats[b]) return false;
[seats[a], seats[b]] = [seats[b], seats[a]];
return true;
},
// The one check a banker actually needs, and the one nothing here did.
//
// Every chip on the table is a promise to pay cash for it later. If the chips in
@@ -488,10 +719,19 @@ const HoldemState = {
0
);
const difference = Money.round(chipsInPlay - cashIn);
// Chips the dealer deliberately took off the table — an abandoned pot they
// were asked about and confirmed. They are gone, so they can't be counted as
// in play, but they aren't missing either: the box knows exactly where they
// went. Without this the check blamed the tool's own confirmed action on
// "someone has paid for chips they haven't been given", which sends a dealer
// hunting for a miscount that never happened.
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
const difference = Money.round(chipsInPlay + discarded - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
+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");
});
+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");
});
});
+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);
});
+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);
});
+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`);
});
});
});
+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);
});
+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;
});
});
+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);
});
+53
View File
@@ -0,0 +1,53 @@
// Which fields replace their contents when a dealer reaches for them.
const test = 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);
});
+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());
}));
});
+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);
});
+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");
});
+64 -10
View File
@@ -180,6 +180,12 @@ const LayoutAudit = {
collapsedColumns() {
const offenders = [];
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
// Nothing inside something the page has hidden. A hidden element's children
// all measure zero wide, so the standings header — which is hidden along
// with its table until somebody has bought in — was reported as three
// collapsed columns on every empty table. A column nobody can see hasn't
// collapsed; it isn't there.
if (row.closest("[hidden]")) return;
if (getComputedStyle(row).display !== "grid") return;
[...row.children].forEach(cell => {
if (getComputedStyle(cell).display === "none") return;
@@ -343,6 +349,16 @@ const LayoutAudit = {
});
},
// Every element between this one and the document, so an opacity set on a
// container can be seen from the text inside it.
*ancestors(el) {
let node = el.parentElement;
while (node) {
yield node;
node = node.parentElement;
}
},
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];
@@ -352,19 +368,50 @@ const LayoutAudit = {
: [...document.querySelectorAll("*")];
nodes.forEach(el => {
if (el.children.length > 0) return; // leaf text only
const text = (el.textContent || "").trim();
// An element's *own* text, not its descendants' — they get measured on
// their own turn, against their own colour.
//
// This used to be "leaf elements only", which quietly stopped measuring
// anything the moment a child was added to it. The undo control went that
// way the day a <kbd> was appended to it: its label became invisible to
// this check, with no sign that anything had changed.
const text = [...el.childNodes]
.filter(node => node.nodeType === 3)
.map(node => node.textContent)
.join(" ")
.trim();
if (!text) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const style = getComputedStyle(el);
if (style.visibility === "hidden" || style.display === "none") return;
if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
// Decoration is exempt by definition: the watermark on the felt is meant
// to be barely there, and holding it to a reading threshold would mean
// making it not a watermark. Marked aria-hidden so screen readers skip it
// too, which is the same claim expressed where it also does some good.
if (el.closest("[aria-hidden='true']")) return;
// Faded text used to be skipped outright as "faded on purpose", and that
// exemption hid a real fault: the folded and sat-out seats dimmed their
// whole box with opacity, so a folded player's stack measured 2.22:1 at
// 11.5px and this check never looked at it. Something invisible is still
// exempt — there's nothing to read at zero — but anything in between is
// reported as unmeasured rather than passed over in silence, because a
// number nobody checked is not the same as a number that's fine.
const opacity = parseFloat(style.opacity);
if (opacity <= 0.02) return;
const faded = opacity < 0.99 ||
[...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
if (faded) {
unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
return;
}
// aria-hidden used to exempt text here, on the grounds that decoration
// marked for screen readers was decoration for everyone. Those are two
// different questions and the answer differs: the bet chips on the felt are
// aria-hidden because their amounts are already announced by the seat and
// the action bar, and repeating them would be noise — but they are exactly
// what a sighted dealer reads the felt for, and this check had stopped
// looking at them. Hiding something from one audience is not a claim about
// whether the other audience can read it.
//
// 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.
const fgRaw = this.parseColour(style.color);
if (!fgRaw) return;
@@ -522,8 +569,15 @@ const LayoutAudit = {
const chips = [...document.querySelectorAll(".dealerChip")];
const shown = chips.filter(chip => {
const style = getComputedStyle(chip);
return style.display !== "none" && style.visibility !== "hidden" &&
parseFloat(style.opacity) > 0.05;
if (style.display === "none" || style.visibility === "hidden") return false;
if (parseFloat(style.opacity) <= 0.05) return false;
// A hovered seat deliberately fades its empty slot in — that's the "put the
// button here" preview, and it is on screen because a pointer is sitting on
// that seat. Counting it reports two buttons whenever the mouse happens to
// rest on the table, which is most of the time a mouse exists.
const seat = chip.closest(".seat");
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
return true;
});
const offenders = [];