Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 9ee4816fa4 Make the overlays usable from a keyboard
Measured every overlay in the tool before touching anything. For a dealer running
this on a laptop, all six were broken in the same three ways.

- focus never moved into the dialog: 0 of 5 measured
- Tab walked straight out of the dialog into the page behind the scrim: 5 of 5
- Escape did nothing: only the rulings sheet handled it, 1 of 5
- the pot wizard — the dialog that pays people — had no role and no
  accessible name at all

So opening the pot wizard left focus on the button behind it, tabbing wandered
through a page nobody can see or click, and there was no way back out except the
mouse.

One implementation, not six

The overlays are opened and closed by five different modules, which is why they
each missed the same things. Rather than patch this into all of them, modal.js
watches the `hidden` attribute they already toggle: focus moves to the first real
control on open, Tab and Shift-Tab wrap inside the dialog, Escape closes it, and
focus returns to whatever opened it. `aria-modal` is set while open and removed
on close. An overlay added later gets all of it without anyone remembering to
wire it up, which is the failure being fixed.

Escape presses the dialog's own cancel button rather than just hiding the
overlay, so the confirm dialog still resolves its promise — verified: Escape
resolves it to false rather than leaving an await hanging forever.

Shortcuts.overlayOpen() had the same shape of bug, listing the six overlay ids by
hand. It asks the document now, so a sheet added later can't quietly start
letting "f" fold the table behind it.

Focus you can see

Three focus rules existed for the whole stylesheet, so tabbing relied on the
browser default ring, which on these dark custom-painted controls is close to
invisible. `:focus-visible` keeps it to keyboard use so a dealer tapping Fold
isn't left with a ring on it.

That surfaced something small: `.holdemBtn` used `transition: all`, so the new
outline faded in over 150ms and the ring lagged a step behind the Tab key. The
transition names its properties now. Measured after: 16 of 16 tab stops show a
ring immediately, and the press state from before still animates.

Also

- a mistyped negative stack read back as negative, and flowed into putIn() where
  Math.min(amount, -50) moves a negative amount and adds chips to the table.
  Clamped at zero.
- accessible names for the chip colour, chip value and notes fields, which had none

11 new tests (99 total), covering negative stacks, negative and unreadable
blinds, blinds nobody can cover, and a save that comes back corrupt, truncated,
or from before a field existed.

Playwright: all five overlays re-measured — focus moves in 5/5, Tab and
Shift-Tab trapped 5/5, Escape closes 5/5, focus restored 5/5, role and
aria-modal present 5/5. Shortcuts still fire when no dialog is open and are
still blocked when one is. Touch guarantees from the earlier round unchanged:
0 of 49 controls on touch-action auto, 0 tap-highlighted.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 04:12:55 -06:00
jheapsandClaude Opus 5 db208a1a86 Make big numbers readable, and make the export add up
Seeded a table that looks like a real game — "Mike from work", "Jean-Philippe",
stacks from $12 to $3,200,000 — and measured what the layout does with it.

".00" was costing a third of a seat puck

Every amount rendered with two forced decimal places. Measured, the trailing
".00" is ~22px, against the 64px a seat puck has to show a stack in. So
"$1,250,000.00" needed 102px of a 64px box — and because a number has no spaces
to break on, it neither wrapped nor ellipsised. It drew straight out over the
seat next to it.

Money now shows cents only when there are cents. A chip game deals in whole
numbers nearly all the time, and "$12" says exactly what "$12.00" said in half
the width. A quarter-blind game is untouched: $0.25, $12.50 and $0.75 pots all
still read as before, checked against a 25c/50c table.

That alone took "$1,250,000.00" down to "$1,250,000" — 102px to 80px, still
wider than the 64px puck. The remaining overflow is handled by stepping the font
down on long amounts, which keeps every digit. Abbreviating to "$1.25M" would
have fit more comfortably and was the obvious move, but a dealer reading a
rounded stack off the felt is a worse failure than a small one, and the exact
figure is the entire point of the tool. Measured after: every stack now paints
inside its own puck, nothing spills onto a neighbour.

Because Money.format is the single formatter all 47 call sites go through, this
lands on the seat pucks, action bar, pot breakdown, seat menu, standings and
history at once — the payoff from consolidating it earlier.

The CSV export couldn't be added up

The file exists so a house manager or a disputed chip count has something that
outlives the tab, and every money column in it was written as text:
"$1,250,000.00". The commas were quoted correctly so the file always parsed —
it just imported as strings, so the one thing anyone opens it to do, total a
column, needed every cell cleaned first.

Money columns now go in as bare numbers (1250000.00) with no symbol and no
separators. The columns meant for humans stay human: names are still names,
payouts still read "Alexandra: $1,250,000", and a name with a comma in it is
still quoted.

12 new tests (88 total, all passing), including the case that would be ruined by
dropping cents unconditionally, and a proper quote-aware CSV parser to check
every money cell parses as a number.

Re-ran the occlusion sweep from the previous round at 9 seats, 10 seats and
tournament stacks: still clean, no regression from the font changes.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:54:03 -06:00
jheapsandClaude Opus 5 3d63ba50ee Handle the messy table: people joining, leaving, and arguing about blinds
Home games don't run like a cardroom. People walk up mid-hand, storm off
mid-hand, and pass the button round three times before anyone deals. Probing
each of those found four problems, one of which loses chips.

A player taken out mid-hand took their chips with them

`computeContribution` opened with `if (!player.inHand) return 0`, so the moment
a dealer switched a seat off, everything that seat had already put in vanished
from the pot. Measured: four players in for $2 each, one taken out, pot drops
from $8 to $6. Their $2 was still sitting on the seat record — it just stopped
counting. A dealer following the tool would have paid out $6 of an $8 pot.

Chips already pushed into the middle are dead money. They stay in the pot and go
to whoever wins, whether or not the player is still in the hand, and it is not
theirs to take back. The `inHand` check now guards only the table-wide base-pot
default, which does have to stay tied to being dealt in or every empty chair
would be treated as having anted. The pot wizard counts a departed seat as
folded, so its chips are in the pot but it can never be picked as a winner.

Passing the button before the cards come out

Moving the button left the posted blinds where they were while everything
positional started reading off the new spot, so the money and the blind
positions silently disagreed. While nothing but the blinds are in, moving the
button now refunds them and re-posts from where it lands — pass it round six
times and the chip count is unchanged. Once anybody has acted the blinds are
settled and it stops offering.

Naming the blinds by hand

Blinds were purely positional with no way to overrule them. The seat menu now
offers "Make small blind" / "Make big blind" while only the blinds are in, and a
badge with a ring marks a blind that was named rather than dealt. Naming one
pins the other where it is. It's refused if it would put both blinds on one
player or post to somebody who isn't dealt in, and it lasts one hand — carrying
it forward would freeze the blinds on two people for the night.

The first hand, and a table that empties out

A fresh table puts the button on seat 1 whether or not anyone is sitting there,
and a table can empty out around it. The button is moved onto a real player
before a hand is dealt.

Joining mid-hand

A newcomer was added straight into the live seats and would be asked to act in a
hand they hadn't been paying into. They now sit the hand out and are a full
player from the next one.

Nothing is covered on a phone any more

Swept 13 viewport sizes with elementFromPoint, five points per control, on the
table and every overlay. Before: the action bar covered the bottom seats on
short screens (62px of felt at 360x640), the toast covered up to six seats at
once — Undo button included, so a tap meant for a player hit "Undo" — and in
landscape the table came out 765px tall in a 390px window, putting six of nine
seats off-screen entirely.

- the action bar's real height is published as `--actionBarHeight` and drives
  both the page's bottom padding and the toast, replacing a hardcoded 9rem that
  was wrong the moment the raise pad opened
- landscape sizes the felt from the height that's left, not from width. The
  first attempt did nothing: `padding-bottom` is a percentage of the containing
  block's *width*, so narrowing the wrap left it full height, and a
  `max-width: 900px` rule later in the file was overriding it anyway. It's set
  outright now and lives at the end of the sheet.
- on short screens the toast floats over the action bar's status readout rather
  than over the seats. Nowhere is clear of both; covering something you re-read
  beats covering something you tap.
- headers go compact on short screens, which is where the vertical room came from

13/13 sizes clean, 360x640 through 844x390, portrait and landscape.

Also fixes a marker that flickered: `classList.toggle(cls, undefined)` toggles
rather than clearing, and `manual` is undefined for positional blinds, so the
hand-picked ring appeared on alternate renders.

18 new tests (76 total, all passing).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:40:02 -06:00
jheapsandClaude Opus 5 073ba9a252 Make every tap in the dealer land instantly on a phone
Four measured problems, all of which only show up under a thumb.

Every tap waited on the browser (53 of 53 controls)

Not one interactive element set `touch-action`, so a mobile browser held every
tap for ~300ms to see whether a second one was coming that would mean "zoom in".
That is on the fold button, the call button, every seat, every card slot.

Worse, this tool uses double-tap on a seat as the fold shortcut — which *is* the
browser's zoom gesture. The dealer's fastest shortcut was fighting the page
zooming under their thumb. A single seat tap cost ~300ms of browser hesitation
before the app's own 220ms double-tap window even started.

`touch-action: manipulation` everywhere keeps panning and pinch-zoom and drops
only double-tap-to-zoom, which is precisely the one that was in the way.

Press feedback was the OS grey box (1 `:active` rule in 2385 lines)

Only `.actionBtn` had a pressed state. Everything else — seats, header buttons,
card slots, picker cards, menu rows, toasts, wizard choices — relied on the
translucent grey rectangle the browser paints on tap. So the tap highlight
couldn't just be switched off; the controls needed real pressed states first.
They press down 1px and brighten, in 0.04s, matching the action bar that already
did. Then the grey box goes.

Hover stuck after every tap (21 unguarded rules)

`:hover` has no meaning on touch, but it still applies — and stays applied until
you tap elsewhere. Tapping "Next hand" left it lifted and lightened as though
still under a cursor, and it would have fought the new pressed states directly.
All of them are behind `@media (hover: hover)` now. The one rule that shared a
block with `:focus-visible` was split so keyboard focus survives.

The notes field zoomed the page (iOS)

`#tableNotes` was 13.3px. iOS zooms the viewport when a field under 16px takes
focus, and the dealer is then pinching back out to see the table. Every other
input here was already 16px; this was the straggler.

Seats deliberately have no `:active` rule

Worth writing down because it nearly went in. A seat's `transform` is what
positions it around the felt, so a pressed state setting `transform` drops the
`translate(-50%, -50%)` and throws every pressed seat ~38px off its spot.
Validation caught it: seats already dip via `.seatPressing`, which `pointerdown`
adds in render.js — earlier than `:active` resolves, and it restates the
translate correctly.

Validated with Playwright at 390x844: 53/53 controls off `touch-action: auto` and
0 tap-highlighted across the table, all four overlays and Quick Split; text
fields still selectable and buttons not; 14/14 hover rules gated; presses
measured live via mouse-down (fold reads translateY(1px)/brightness(1.3), a seat
holds its translate with 2px of drift from the shrink alone). Hand played through
the UI including a double-tap fold. No console errors. Unit tests 58/58.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:11:39 -06:00
jheapsandClaude Opus 5 c1a484b97a Pay down the Hold'em dealer's tech debt and give it a test suite
Every module in the dealer ends with a `module.exports` guard, so they were all
written to be testable, and none of them were ever tested. The correctness fixes
that went in recently were checked by hand in a browser console that has since
been closed. This puts a real safety net under them and clears out the
duplication that made those bugs possible in the first place.

Tests (58, ~300ms, no dependencies)

Node's built-in runner, no package.json and no install step: the modules are
plain script-tag files, so `load.js` just requires them and puts them on
globalThis under the names they use to find each other in the page.

The randomised suite is the part that matters. Every bug found in this tool so
far has been shape-dependent — blinds posted to empty chairs, the button
skipping players, heads-up acting out of order — and none of them show up on the
full six-handed table anyone would write a fixed example for. It deals 1000
hands per property across random seat counts, empty chairs, stack depths and
dealer input, checking that chips are never created or destroyed, that the pot
breakdown plus uncalled returns equals the money in, that paying out settles to
the cent, and that the button and blinds always land on real players. The
generator is seeded so a failure reproduces instead of flaking.

One implementation each

- `Math.round(x * 100) / 100` was written out 16 times across 6 files. It's
  `Money.round` / `toCents` / `fromCents` now, in the module both pages already
  load first.
- The chip epsilon was declared twice with the same value under two names
  (`PotCalculator.EPSILON`, `Betting.EPSILON_CHIPS`). Both now point at
  `Money.EPSILON`.
- `Betting.seatAt` and `HoldemState.nextSeatIndex` were the same modular
  arithmetic written twice. Walking the seats is exactly where the button, the
  blinds and the action drifted apart from each other before, so there is one
  of it now.
- The undo snapshot/restore dance appeared three times in main.js, each one
  re-deleting every key on `state` in place. It's `snapshotState` /
  `restoreState` once, with the reason it can't just reassign written down.

A bug the tests found

`layers()` and `uncalledReturns()` both decide what money was never contested,
separately, and disagreed. `uncalledReturns` measured each player against the
biggest live contribution *including their own*, which makes the biggest bet its
own yardstick — so the one case it exists for came back empty, and `layers()`
announced the overbet as a side pot only the bettor could win.

With Ann shoving 200 into Ben's 60, the dealer was told "main pot 4, side pot 3,
side pot 116, side pot 140" — four pots, one of them fictional. It now reads
"main pot 123, uncalled 140 back to Ann", which is what a dealer says out loud.
Layers are derived from contributions with the uncalled money already removed,
so the two can't disagree again. A band every payer folded out of is now merged
into the pot below rather than dropped, which was quietly shrinking the
announced pot in another edge case.

Validated with Playwright against a real hand on both surfaces — the table's
live pot readout and Quick Split's all-in mode — plus undo, with no console
errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:00:36 -06:00
jheaps ae8744672f Merge remote-tracking branch 'origin/feature/casino-quicksplit-side-pots' into feature/casino-holdem-dealer-batch 2026-09-01 02:38:01 -06:00
jheaps 328ef56af5 Merge remote-tracking branch 'origin/feature/casino-holdem-contrast' into feature/casino-holdem-dealer-batch 2026-09-01 02:38:01 -06:00
jheaps 7714e16547 Merge remote-tracking branch 'origin/feature/casino-holdem-touch-audit' into feature/casino-holdem-dealer-batch 2026-09-01 02:38:01 -06:00
jheaps cc31cd033b Merge remote-tracking branch 'origin/feature/casino-holdem-wizard-polish' into feature/casino-holdem-dealer-batch
# Conflicts:
#	JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
2026-09-01 02:38:01 -06:00
jheaps f324793732 Merge remote-tracking branch 'origin/feature/casino-holdem-seat-count-safety' into feature/casino-holdem-dealer-batch 2026-09-01 02:37:41 -06:00
jheaps 56d9d740b5 Merge remote-tracking branch 'origin/feature/casino-holdem-odd-chip' into feature/casino-holdem-dealer-batch 2026-09-01 02:37:41 -06:00
jheaps e5b02b00e4 Merge remote-tracking branch 'origin/feature/casino-holdem-button-moves' into feature/casino-holdem-dealer-batch 2026-09-01 02:37:41 -06:00
jheaps 34cd7dbc59 Merge remote-tracking branch 'origin/feature/casino-holdem-allin-runout' into feature/casino-holdem-dealer-batch
# Conflicts:
#	JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
2026-09-01 02:37:41 -06:00
jheapsandClaude Sonnet 5 4f72dda6fa Ask before a smaller table deletes the players sitting in it
Typing a smaller number into "Seats at table" silently threw away every seat past
the new count. Nine players down to six removed three of them — names, stacks and
buy-ins gone, no confirmation, no way back.

It also quietly falsified the settle-up. Those players keep their rows in the
session standings, because the hand log remembers who won what, but the seat they
were read from no longer exists — so a player who had just won a pot showed as
having bought in for nothing and holding nothing.

Shrinking now says who it is about to remove and asks, and either way the change
is undoable like the rest of the table's actions. Growing the table, and
shrinking onto chairs nobody is sitting in, still happen without a word.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 02:33:03 -06:00
jheapsandClaude Sonnet 5 18bb23ad6d Pass the button to the next player, not the next chair
The button moved one seat per hand regardless of whether anyone was sitting
there. At a ten-seat table with four players — an ordinary home game — it spent
five hands out of eight on an empty chair.

That reads as untidy, but the real problem is fairness. Position and the blinds
are meant to come round to each player at the same rate. Rotating past empty
chairs breaks that: a player with three empty seats to their right takes the
button far less often than one sitting in a cluster, while both pay blinds every
time the rotation reaches them.

The button now skips to the next seat that's actually dealt in, so with four
players it visits those four in turn and each takes it once every four hands.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:50:16 -06:00
jheapsandClaude Sonnet 5 ae591e4212 Stop asking all-in players to act
A player who is all-in is still in the hand and still wins pots, but has nothing
left to decide. The engine didn't know that: it picked the next player to act by
walking to whoever was still in the hand, chips or no chips.

So a dealer with three players all-in pre-flop was asked to check for each of
them on the flop, the turn and the river — nine taps to say nothing — and the
action could land mid-street on someone who'd already pushed their last chip in.

Now the action passes over players who are all-in, and once fewer than two
people have chips left the betting is simply over: the bar runs Deal Flop, Turn,
River, Showdown straight through, and says "All-in — running out the board" so
the absence of a question reads as deliberate rather than as a stuck screen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:37:19 -06:00
jheapsandClaude Sonnet 5 89d79a0f7d Post the blinds to the players rather than to the chairs
Blinds were assigned by seat offset — always button+1 and button+2 — which is
only right when everyone is dealt in and there are three or more of them.

Heads-up it had the blinds backwards. The button posted the big blind and acted
second before the flop; the rule is the opposite on both counts. That's the end
of every home tournament, dealt wrong.

Worse, the offsets don't care whether anyone is sitting in those chairs. With
two players at a nine-handed table, or with the seats left of the button sitting
out, the blinds landed on empty seats and simply weren't posted — no money in
the middle, and SB/BB badges pointing at nobody.

Blinds now walk the players who are actually dealt in, starting from the button,
with heads-up as its own case: the button is the small blind, acts first
pre-flop, and the big blind acts first afterwards. The badges come from the same
function as the money, so what the table shows and what gets posted can't
disagree.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:22:15 -06:00
jheapsandClaude Sonnet 5 d0f75d0c14 Lift the faint greys to something readable in a dim room
This is a dark interface used in poker rooms and kitchens with the lights down,
and I'd been generous with grey-on-black. Measured every piece of text against
what's actually painted behind it: thirteen declarations came in under 4.5:1.

The worst was the empty card slot's "+" at 2.63:1 — an affordance telling the
dealer the slot is tappable, and the least legible thing on the screen. Then a
run of #777 labels around 3.9 to 4.1: the standings header, the time on a logged
hand, the currency mark in the add-chips field. The rulings source line sat at
3.62 and the rulings link itself at 4.39.

All of them move up just far enough to clear 4.5 rather than being flattened to
white, so the hierarchy between a heading, a value and a hint survives.

Worth recording what the measurement can't see: it reads background-color, so
text on a gradient — the pot badge, the dealer button, the blind badges — comes
back as a false result in both directions, and those were checked by eye
instead. The felt watermark is deliberately at 6% and stays that way.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:06:20 -06:00
jheapsandClaude Sonnet 5 6b3a7dc09d Bring every phone target up to size, not just the ones I happened to check
Last round I measured one screen and found the smallest targets in the tool were
on it. So I measured all of them: sixty-four controls at phone width, flagging
anything under 44px. Twenty-three came back.

The sidebar had never had the touch pass the seat form got — its inputs, selects
and small buttons sat at 26 to 30px, and the buttons that delete a chip colour
or a custom field were 28px wide, which is a bad size for something destructive.
The rulings link was a 14px-tall underlined word.

The seat form itself had drifted: its mobile sizing rule named five fields, and
the stack field added two rounds later wasn't one of them, so it sat at 28px
beside its 44px neighbours. Both that rule and the new sidebar one are now
written against the form and the panel rather than a list of field names, so the
next field added inherits the size instead of waiting to be noticed.

The dealer button keeps its 22px disc — it's a chip sitting on a seat, and
drawing it thumb-sized would swallow the puck — but takes taps through an
invisible 44px pad. Verified the seat underneath still gets its own taps.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 00:38:29 -06:00
jheapsandClaude Sonnet 5 38488f2978 Give the pot wizard the same tap targets as everything else
Measured the screen where the money actually gets decided and found the smallest
targets in the tool: 37px winner rows, 13px checkboxes, a 36px confirm. Every
other surface has been held to 44px and up for several rounds — the one place a
mis-tap pays the wrong player was the one place that hadn't been.

Winner rows are now 56px and up, with a 22px box and a green fill when chosen,
because a dealer confirms this with players watching and a small checkbox is
easy to misread across a table. The row already was the tap target; now it looks
like one.

Each row also splits into name, what they can win, and their cards as separate
pieces rather than one run-on sentence, so the hand can be read at a glance. The
confirm takes the full width, and on a phone the summary's buttons stack with
the one that ends the hand furthest from a stray thumb.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 00:22:14 -06:00
jheapsandClaude Sonnet 5 7c64749267 Let Quick Split handle the pot it used to send people away for
Quick Split answered the easy half of the question and printed a footnote for
the hard half: if somebody was all-in for less, go set up the full table. That's
the moment a dealer least wants to be setting up a table.

A second mode takes what each player put in and lists the main pot and each side
pot with who can win it, plus anything uncalled that goes back. Rows can be
marked folded, so dead money lands in the right pot without its owner being able
to win it. The running total is shown so the breakdown can be checked against
the chips actually on the table.

It reuses the same pot maths as the full table rather than growing a second
implementation, which means the two screens can't drift apart and Quick Split
inherits the uncalled-bet and everyone-folded handling that was fixed there.

Also fixes hiding: these sections carry a `display` declaration, which outranks
the browser's own [hidden] rule, so toggling `hidden` on them did nothing and
both modes' controls showed at once.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 00:07:37 -06:00
30 changed files with 3158 additions and 202 deletions
+26
View File
@@ -0,0 +1,26 @@
# Unit tests for the Texas Hold'em dealer's money logic.
#
# The betting engine, the side-pot calculator and the table state are plain script-tag
# modules with no build step, so they run directly under Node's built-in test runner —
# no package.json, no install step, nothing to keep up to date.
name: Hold'em tests
on:
pull_request:
branches: [ "master" ]
push:
branches: [ "master" ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Run Hold'em unit tests
run: node --test tests/holdem/*.test.js
+12 -11
View File
@@ -138,7 +138,7 @@
<details class="sidebarPanel">
<summary>Table Notes</summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
</aside>
@@ -198,8 +198,8 @@
<template id="chipLegendRowTemplate">
<div class="chipLegendRow">
<input type="color" class="chipColorInput" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
<input type="color" class="chipColorInput" aria-label="Chip colour" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
</div>
</template>
@@ -253,7 +253,7 @@
</div>
<div class="addChipsActions">
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
<button id="addChipsCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
</div>
@@ -263,7 +263,7 @@
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="rulingsHeader">
<h2 id="rulingsTitle">Common rulings</h2>
<button id="closeRulingsBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<p class="rulingsIntro">
@@ -314,7 +314,7 @@
<span id="seatMenuSubtitle"></span>
</div>
<div id="seatMenuActions"></div>
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
@@ -324,7 +324,7 @@
<div class="cardPickerHeader">
<h2 id="cardPickerTitle"></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<div id="cardPickerSlots"></div>
<div id="cardPickerGrid"></div>
@@ -340,17 +340,17 @@
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
<div class="confirmActions">
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
</div>
</div>
</div>
<div id="potWizardOverlay" hidden>
<div id="potWizardModal">
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
<div class="potWizardHeader">
<h2>Resolve the Pot</h2>
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
<h2 id="potWizardTitle">Resolve the Pot</h2>
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
</div>
<div id="potWizardBody"></div>
</div>
@@ -374,5 +374,6 @@
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
}
@@ -11,6 +11,11 @@
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
</header>
<div id="modeToggle" class="chipRow" role="group" aria-label="How the money went in">
<button type="button" id="modeEvenBtn" class="choiceChip selected">Everyone matched</button>
<button type="button" id="modeUnevenBtn" class="choiceChip">Someone's all-in</button>
</div>
<div id="splitResult" aria-live="polite">
<div id="splitResultLabel">Each player gets</div>
<div id="splitResultAmount">$0.00</div>
@@ -18,7 +23,18 @@
<div id="splitDustNote" hidden></div>
</div>
<section class="splitField">
<div id="unevenResult" hidden aria-live="polite">
<div id="unevenPots"></div>
<p id="unevenEmpty" class="unevenEmpty">Enter what each player put in.</p>
</div>
<section class="splitField unevenOnly" hidden>
<span class="splitLabel">What each player put in</span>
<div id="unevenRows"></div>
<button type="button" id="addPlayerBtn" class="splitBtn splitBtnGhost">+ Add player</button>
</section>
<section class="splitField evenOnly">
<label for="potInput" class="splitLabel">Pot</label>
<div class="potInputWrap">
<span class="potCurrency">$</span>
@@ -28,12 +44,12 @@
</div>
</section>
<section class="splitField">
<section class="splitField evenOnly">
<span class="splitLabel" id="waysLabel">Split between</span>
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
</section>
<section class="splitField">
<section class="splitField evenOnly">
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
</section>
@@ -43,12 +59,30 @@
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
</div>
<p class="splitFootnote">
Uneven pots — someone all-in for less — need the side-pot walkthrough on the
<a href="/casino/texasholdem">full table</a>.
<p class="splitFootnote evenOnly">
If somebody was all-in for less than the others, switch to
<strong>Someone's all-in</strong> above.
</p>
<p class="splitFootnote unevenOnly" hidden>
Pots are listed with who can win each. Award them by hand strength, best hand
first. Nothing here is saved — the <a href="/casino/texasholdem">full table</a>
keeps a log.
</p>
</div>
<template id="unevenRowTemplate">
<div class="unevenRow">
<span class="unevenRowLabel"></span>
<div class="unevenAmountWrap">
<span class="unevenCurrency">$</span>
<input type="text" class="unevenAmount" inputmode="decimal" autocomplete="off" placeholder="0" />
</div>
<button type="button" class="unevenFoldBtn" aria-pressed="false">Folded</button>
<button type="button" class="unevenRemoveBtn" aria-label="Remove player">✕</button>
</div>
</template>
@section Styles {
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@@ -56,5 +90,6 @@
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
}
+572 -61
View File
@@ -6,6 +6,148 @@ body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ---------- Touch ----------
A dealer runs this one-handed on a phone with players waiting, so a tap has to
register the instant their thumb lands.
`touch-action: manipulation` is the whole game here. Without it a mobile browser
holds every tap for ~300ms to see whether a second one is coming that would mean
"zoom in" — on top of which double-tapping a seat, which this tool uses for fold,
*is* the browser's zoom gesture, so the dealer's shortcut fights the page zooming
under their thumb. `manipulation` keeps panning and pinch-zoom and drops only the
double-tap-to-zoom, which is exactly the one we don't want.
The tap highlight goes with it: every control below gets a real pressed state, and
the browser's translucent grey rectangle on top of a designed one just looks like
a rendering artefact. Text selection goes too — a fast tap-and-hold on a seat
should open the seat menu, not raise a text-selection magnifier over the felt. */
.holdemBtn,
.seat,
.dealerChip,
.cardSlot,
.linkBtn,
.seatMenuBtn,
.seatSheetClose,
.pickerCard,
.actionBtn,
.raiseQuickBtn,
.actionUndo,
.toastAction,
.toastClose,
.potWizardChoice,
.subPanel summary,
button,
[role="button"],
input,
select,
textarea,
a {
touch-action: manipulation;
}
/* The grey rectangle goes from every control, fields included — a tapped input
already shows a caret and a focus ring, which say the same thing more quietly. */
.holdemBtn,
.seat,
.dealerChip,
.cardSlot,
.linkBtn,
.seatMenuBtn,
.seatSheetClose,
.pickerCard,
.actionBtn,
.raiseQuickBtn,
.actionUndo,
.toastAction,
.toastClose,
.potWizardChoice,
.subPanel summary,
button,
[role="button"],
input,
select,
textarea,
a {
-webkit-tap-highlight-color: transparent;
}
/* Text selection is switched off only on the things you press, never on the fields
you type into — a dealer editing a player's name still needs to select the text. */
.holdemBtn,
.seat,
.dealerChip,
.cardSlot,
.linkBtn,
.seatMenuBtn,
.seatSheetClose,
.pickerCard,
.actionBtn,
.raiseQuickBtn,
.actionUndo,
.toastAction,
.toastClose,
.potWizardChoice,
.subPanel summary,
button,
[role="button"] {
-webkit-user-select: none;
user-select: none;
}
/* ---------- Pressed states ----------
Touch has no hover, so the press is the only feedback a dealer gets that the tap
landed — and with the grey highlight suppressed above, these have to carry it.
Everything presses the same way it would on a real table: down and slightly
darker, no travel time. */
.holdemBtn:active:not(:disabled),
.linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled),
.toastAction:active,
.toastClose:active,
.potWizardChoice:active,
.raiseQuickBtn:active:not(:disabled),
.actionUndo:active:not(:disabled) {
transform: translateY(1px);
filter: brightness(1.25);
transition-duration: 0.04s;
}
.cardSlot:active,
.pickerCard:active:not(:disabled),
.dealerChip:active {
transform: translateY(1px) scale(0.97);
transition-duration: 0.04s;
}
/* The action bar already presses down; the brightness is what it was missing, and
it's the one place a dealer taps without looking. */
.actionBtn:active:not(:disabled) {
filter: brightness(1.3);
transition-duration: 0.04s;
}
/* Seats deliberately have no `:active` rule. They already dip via `.seatPressing`,
which `pointerdown` adds in render.js — that fires earlier than `:active` resolves,
and, more importantly, a seat's `transform` is what positions it around the felt
(`translate(-50%, -50%)` plus its own left/top). A press state setting `transform`
would drop that translate and throw every pressed seat about 38px off its spot. */
/* Nobody should get a stuck press state from a tap that turned into a scroll. */
@media (prefers-reduced-motion: reduce) {
.holdemBtn:active:not(:disabled),
.linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled),
.cardSlot:active,
.pickerCard:active:not(:disabled),
.dealerChip:active,
.raiseQuickBtn:active:not(:disabled),
.actionUndo:active:not(:disabled) {
transform: none;
}
}
#holdemApp {
max-width: 1400px;
margin: 0 auto;
@@ -77,7 +219,11 @@ body {
padding: 0.6rem 1rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s ease;
/* Named rather than `all`, so the focus ring isn't animated. With `all` the
outline faded in over 150ms, which on keyboard navigation reads as the ring
lagging a step behind the Tab key. Nothing else here changes. */
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease,
box-shadow 0.15s ease, color 0.15s ease, opacity 0.15s ease, filter 0.15s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
@@ -85,10 +231,12 @@ body {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.holdemBtn:hover {
background: linear-gradient(180deg, #383838, #2a2a2a);
border-color: #4a4a4a;
transform: translateY(-1px);
@media (hover: hover) {
.holdemBtn:hover {
background: linear-gradient(180deg, #383838, #2a2a2a);
border-color: #4a4a4a;
transform: translateY(-1px);
}
}
.holdemBtnAccent {
@@ -97,8 +245,10 @@ body {
color: #ecffef;
}
.holdemBtnAccent:hover {
background: linear-gradient(180deg, #1a8a44, #0f602c);
@media (hover: hover) {
.holdemBtnAccent:hover {
background: linear-gradient(180deg, #1a8a44, #0f602c);
}
}
.holdemBtnDanger {
@@ -107,8 +257,10 @@ body {
color: #ffd6d6;
}
.holdemBtnDanger:hover {
background: linear-gradient(180deg, #572525, #401818);
@media (hover: hover) {
.holdemBtnDanger:hover {
background: linear-gradient(180deg, #572525, #401818);
}
}
.holdemBtnGhost {
@@ -146,7 +298,7 @@ body {
}
.tableHint {
color: #888;
color: #949494;
font-size: 0.8rem;
text-align: center;
margin-top: 1rem;
@@ -158,8 +310,10 @@ body {
position: relative;
width: 100%;
padding-bottom: 72%;
margin: 0 auto;
}
#pokerTable {
position: absolute;
inset: 6%;
@@ -357,7 +511,7 @@ body {
}
.seatEmptyLabel {
color: #6d6d6d;
color: #8a8a8a;
font-size: 0.6rem;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -373,7 +527,7 @@ body {
top: 2px;
left: 6px;
font-size: 0.58rem;
color: #777;
color: #8a8a8a;
}
.seatPuckName {
@@ -430,11 +584,16 @@ body {
transition: opacity 0.15s ease;
}
.seat:hover .dealerChip,
.dealerChip:focus-visible {
opacity: 1;
}
@media (hover: hover) {
.seat:hover .dealerChip {
opacity: 1;
}
}
.dealerChip.active {
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
border-color: #a9782f;
@@ -474,6 +633,13 @@ body {
color: #fff7e6;
}
/* A blind the dealer named by hand rather than one the button handed out. Without
the ring the table just looks like it's put the blinds in the wrong place. */
.blindBadge.blindManual {
outline: 1.5px solid #f2f2f2;
outline-offset: 1px;
}
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
@@ -567,9 +733,11 @@ body {
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.cardSlot:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
@media (hover: hover) {
.cardSlot:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
}
}
.cardSlot.cardRed {
@@ -578,7 +746,7 @@ body {
.cardSlot.cardSlotEmpty {
background: #191919;
color: #5c5c5c;
color: #8a8a8a;
border-style: dashed;
border-color: #3f3f3f;
box-shadow: none;
@@ -694,7 +862,7 @@ body {
}
.addChipsCurrency {
color: #777;
color: #8a8a8a;
font-size: 1.3rem;
}
@@ -735,7 +903,7 @@ body {
background: none;
border: none;
padding: 0;
color: #a9782f;
color: #d8b46a;
font-family: inherit;
font-size: 0.78rem;
text-decoration: underline;
@@ -743,8 +911,10 @@ body {
cursor: pointer;
}
.linkBtn:hover {
color: #d8b46a;
@media (hover: hover) {
.linkBtn:hover {
color: #d8b46a;
}
}
#rulingsOverlay {
@@ -834,7 +1004,7 @@ body {
margin: 0;
padding-top: 0.6rem;
border-top: 1px solid #2c2c2c;
color: #7a7a7a;
color: #8a8a8a;
font-size: 0.72rem;
line-height: 1.45;
}
@@ -909,9 +1079,11 @@ body {
transition: all 0.12s ease;
}
.seatMenuBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
@media (hover: hover) {
.seatMenuBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.seatMenuBtn.menuDanger {
@@ -1037,8 +1209,10 @@ body {
transition: transform 0.1s ease;
}
.pickerCard:hover:not(:disabled) {
transform: translateY(-1px);
@media (hover: hover) {
.pickerCard:hover:not(:disabled) {
transform: translateY(-1px);
}
}
.pickerCard.cardRed {
@@ -1213,12 +1387,14 @@ body {
.subPanel summary {
cursor: pointer;
color: #8f8f8f;
color: #9a9a9a;
font-size: 0.78rem;
}
.subPanel summary:hover {
color: #c5c5c5;
@media (hover: hover) {
.subPanel summary:hover {
color: #c5c5c5;
}
}
.subPanelBody {
@@ -1249,7 +1425,7 @@ body {
.fieldHint {
font-size: 0.75rem;
color: #888;
color: #949494;
line-height: 1.4;
margin: 0;
}
@@ -1306,7 +1482,7 @@ body {
}
.standingsHeader {
color: #777;
color: #8a8a8a;
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -1374,7 +1550,7 @@ body {
.handHistoryTime {
margin-left: auto;
color: #777;
color: #8a8a8a;
font-size: 0.72rem;
}
@@ -1395,6 +1571,10 @@ body {
padding: 0.5rem;
resize: vertical;
font-family: inherit;
/* iOS zooms the whole page in when you focus a field under 16px, and the dealer
is then pinching back out to see the table. Every other input here is already
at 16px; this one was the straggler. */
font-size: 16px;
}
/* ---------- Pot wizard ---------- */
@@ -1456,10 +1636,12 @@ body.hasActionBar #holdemSidebar {
white-space: nowrap;
}
.actionUndo:hover {
background: #333;
border-color: #4f4f4f;
color: #eee;
@media (hover: hover) {
.actionUndo:hover {
background: #333;
border-color: #4f4f4f;
color: #eee;
}
}
.actionStreet {
@@ -1480,6 +1662,12 @@ body.hasActionBar #holdemSidebar {
font-variant-numeric: tabular-nums;
}
.actionRunout {
margin-left: auto;
color: #d8b46a;
font-size: 0.74rem;
}
.actionWho {
margin-left: auto;
color: #9c9c9c;
@@ -1494,7 +1682,7 @@ body.hasActionBar #holdemSidebar {
flex: none;
min-width: 3.1rem;
text-align: right;
color: #7d7d7d;
color: #949494;
font-variant-numeric: tabular-nums;
transition: color 0.3s ease;
}
@@ -1594,9 +1782,11 @@ body.hasActionBar #holdemSidebar {
transition: all 0.12s ease;
}
.actionBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
@media (hover: hover) {
.actionBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.actionBtn:active {
@@ -1732,11 +1922,29 @@ body.hasActionBar #holdemSidebar {
.seatPuckStack {
font-size: 0.58rem;
color: #8f8f8f;
color: #9a9a9a;
font-variant-numeric: tabular-nums;
margin-top: 0.1rem;
}
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
the seat next to it. These step the size down so every digit stays on its own
puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more
comfortably, but a dealer reading a rounded stack off the felt is a worse failure
than a small one, and the exact figure is what the tool is for. */
.seatPuckStack.puckAmountLong,
.seatPuckTotal.puckAmountLong {
font-size: 0.5rem;
letter-spacing: -0.01em;
}
.seatPuckStack.puckAmountHuge,
.seatPuckTotal.puckAmountHuge {
font-size: 0.44rem;
letter-spacing: -0.02em;
}
.blindsRow {
display: flex;
flex-wrap: wrap;
@@ -1778,8 +1986,13 @@ body.hasActionBar #holdemSidebar {
/* Sit above the action bar rather than on top of it, so an Undo never covers the
Fold button the dealer is reaching for. */
/* Sit the toast directly on top of the action bar, whatever height it happens to be.
This was a hardcoded 9rem, which is roughly right for the bar at rest and wrong the
moment the raise pad opens — and on a short phone it put the toast, Undo button and
all, straight over the bottom seats of the table, so a tap meant for a player hit
"Undo" instead. */
body.hasActionBar #toastHost {
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
bottom: calc(var(--actionBarHeight, 9rem) + 0.75rem + env(safe-area-inset-bottom, 0px));
}
.toast {
@@ -1825,8 +2038,10 @@ body.hasActionBar #toastHost {
cursor: pointer;
}
.toastAction:hover {
background: rgba(216, 180, 106, 0.22);
@media (hover: hover) {
.toastAction:hover {
background: rgba(216, 180, 106, 0.22);
}
}
.toastClose {
@@ -1836,15 +2051,17 @@ body.hasActionBar #toastHost {
border-radius: 50%;
border: none;
background: transparent;
color: #888;
color: #949494;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.toastClose:hover {
background: #333;
color: #ddd;
@media (hover: hover) {
.toastClose:hover {
background: #333;
color: #ddd;
}
}
/* ---------- Confirmation dialog ---------- */
@@ -1962,20 +2179,77 @@ body.hasActionBar #toastHost {
margin-bottom: 0.6rem;
}
/* This is the screen where the money gets decided, so it holds the same 44px+
targets as the rest of the tool rather than the smallest ones in it. Picking the
wrong winner is the one mistake here that costs somebody real money. */
.potWizardChoice {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.7rem;
min-height: 56px;
background: #141414;
border: 1px solid #333;
border-radius: 6px;
padding: 0.5rem 0.7rem;
border-radius: 10px;
padding: 0.6rem 0.8rem;
cursor: pointer;
font-size: 0.85rem;
font-size: 0.95rem;
transition: border-color 0.12s ease, background 0.12s ease;
}
.potWizardChoice:hover {
border-color: #555;
@media (hover: hover) {
.potWizardChoice:hover {
border-color: #555;
background: #191919;
}
}
.potWizardChoice input {
flex: none;
width: 22px;
height: 22px;
accent-color: #1e8449;
}
/* Selected has to be obvious at a glance — a dealer confirms this with players
watching, and a checkbox alone is easy to misread across a table. */
.potWizardChoice.chosen {
border-color: #1e8449;
background: linear-gradient(180deg, #16301f, #12241a);
}
.potWizardChoice.chosen .potWizardChoiceName {
color: #d6ffe0;
}
.potWizardChoiceText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.potWizardChoiceName {
color: #f0f0f0;
font-weight: 600;
}
.potWizardChoiceUpTo {
color: #9a9a9a;
font-size: 0.76rem;
font-variant-numeric: tabular-nums;
}
.potWizardChoiceCards {
flex: none;
font-family: var(--font-mono, monospace);
font-size: 1rem;
font-weight: 700;
color: #ddd;
}
.potWizardChoiceCards .cardRed {
color: #ff6b6b;
}
.potWizardSummary ul {
@@ -1996,6 +2270,37 @@ body.hasActionBar #toastHost {
gap: 0.6rem;
}
/* Every button in this modal is one a dealer presses with players waiting. */
#potWizardBody .holdemBtn {
min-height: 48px;
justify-content: center;
}
/* The confirm is the whole point of the step, so it gets the whole width rather
than being sized to the length of its own label. */
.potWizardRound > .holdemBtn {
width: 100%;
}
.potWizardActions .holdemBtn {
flex: 1;
}
.potWizardSummary li {
font-variant-numeric: tabular-nums;
}
@media (max-width: 700px) {
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
.potWizardActions {
flex-direction: column-reverse;
}
.potWizardChoice {
min-height: 60px;
}
}
@media (max-width: 900px) {
#holdemLayout {
grid-template-columns: 1fr;
@@ -2018,8 +2323,13 @@ body.hasActionBar #toastHost {
seat looks like it did nothing. Inset the ring by half a puck, and float the
editor up as a bottom sheet over the table. */
@media (max-width: 700px) {
/* The bottom padding has to clear the fixed action bar, plus room for a toast to
sit above it. Without it the table just carries on underneath the bar and the
lower seats of the oval are behind the Fold button — measured at 360x640, where
62px of felt was covered. `--actionBarHeight` is published by actionBar.js and
is 0 when no hand is running, so the page doesn't hold a gap it doesn't need. */
#holdemApp {
padding: 1rem 0.75rem 2rem;
padding: 1rem 0.75rem calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px));
}
#holdemHeader {
@@ -2140,11 +2450,10 @@ body.hasActionBar #toastHost {
whole phone width to work with, so everything grows to thumb size — and
every field goes to 16px, below which iOS zooms the page on focus and the
dealer has to pinch back out mid-hand. */
.seatEditorForm .seatNameInput,
.seatEditorForm .amountOverrideInput,
.seatEditorForm .chipCountInput,
.seatEditorForm .customFieldLabel,
.seatEditorForm .customFieldValue {
/* Every text or number field in the form, rather than a list of the ones that
existed when this was written. The stack field was added later and sat at 28px
next to its 44px neighbours precisely because it wasn't on that list. */
.seatEditorForm input:not([type="checkbox"]):not([type="color"]) {
font-size: 16px;
min-height: 44px;
padding: 0.4rem 0.6rem;
@@ -2208,6 +2517,77 @@ body.hasActionBar #toastHost {
margin-top: 0.4rem;
}
/* The sidebar's own controls never got the touch pass the seat form did — these
are settings a dealer changes at the table, on the same phone. Written against
the panel rather than each field, so a setting added later inherits it. */
.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 {
min-height: 44px;
}
/* Destructive and tiny is the worst combination: these delete a chip colour or a
custom field, and were 28px wide. */
.removeChipColorBtn,
.removeFieldBtn {
min-width: 44px;
min-height: 44px;
}
/* flex-shrink would take a stated width straight back off again in a row this
narrow, which is why it stayed 32px wide with the height applied. */
.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;
}
/* A bare checkbox with no label box around it to grow. */
#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;
}
/* The dealer button has to stay a 22px disc — it's a chip sitting on a seat, and
drawing it thumb-sized would swallow the puck. So the disc keeps its size and
an invisible pad around it takes the tap. It reaches outward, away from the
puck's own tap target, so pressing the seat still opens the seat. */
.dealerChip::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
.dealerChip {
overflow: visible;
}
.toastAction {
min-height: 44px;
}
@@ -2221,3 +2601,134 @@ body.hasActionBar #toastHost {
justify-content: center;
}
}
/* ---------- A phone on its side ----------
Deliberately last in the file. The felt is an aspect-ratio box whose height comes
from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100%
— so a landscape rule placed any earlier is overridden at equal specificity and
does nothing, which is exactly what happened on the first attempt.
Landscape is where this layout falls apart worst: sized from width, the table came
out 765px tall in a 390px-tall window, putting six of the nine seats below the fold
and the rest behind the action bar. Sizing it from the height that's actually left
makes every seat reachable without scrolling. The table ends up small, but a seat
you have to go hunting for is worse than a seat that's small. */
@media (orientation: landscape) and (max-height: 560px) {
/* Set outright rather than via padding-bottom: a percentage padding resolves
against the containing block's width, not the element's own, so narrowing the
wrap on its own left it full height regardless. */
#pokerTableWrap {
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem);
width: min(100%, calc(var(--feltRoom) / 0.72));
height: var(--feltRoom);
padding-bottom: 0;
}
/* The header ate 148px of a 390px-tall window, which was most of the reason the
table started below the fold. Here it only needs to be a title bar. */
#holdemHeader {
margin-bottom: 0.4rem;
gap: 0.35rem;
}
#holdemHeader h1 {
font-size: 1.05rem;
padding-bottom: 0.15rem;
}
.holdemSubtitle {
display: none;
}
/* There's no vertical room left, but plenty either side of a table this narrow,
so the toast moves out of the middle and sits beside the felt instead of on
top of it. Centred, it was covering up to six seats at once — and it carries
an Undo button, so a tap meant for a player was landing on "Undo". */
body.hasActionBar #toastHost,
#toastHost {
left: auto;
right: 0.5rem;
max-width: 17rem;
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
}
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
.seat {
width: 62px;
min-height: 40px;
}
}
/* ---------- Short phones, upright ----------
A 640px-tall screen spends ~150px on the header before the felt even starts, which
left the bottom of the table sitting behind the action bar until you scrolled. The
header carries no information a dealer needs mid-hand, so it gives the space up. */
@media (max-height: 700px) {
/* Nowhere on a screen this short is the toast clear of both the table and the
action bar, so it overlaps the one that costs least: it sits over the bar's
status line, which is information a dealer can re-read a second later, rather
than over the seats and buttons, which are things they need to hit. Overlapping
a readout beats covering a control. */
body.hasActionBar #toastHost {
bottom: calc(var(--actionBarHeight, 0px) - 3.1rem);
}
.toast {
padding: 0.5rem 0.7rem;
font-size: 0.78rem;
}
}
@media (max-width: 700px) and (max-height: 700px) {
#holdemHeader {
margin-bottom: 0.5rem;
gap: 0.4rem;
}
#holdemHeader h1 {
font-size: 1.15rem;
padding-bottom: 0.2rem;
}
.holdemSubtitle {
display: none;
}
}
/* ---------- Keyboard focus ----------
Three focus rules existed for the whole tool, so tabbing through it relied on
whatever ring the browser draws by default — which on these dark, custom-painted
controls is close to invisible. `:focus-visible` rather than `:focus` keeps it to
keyboard use: a dealer tapping Fold shouldn't be left with a ring on it. */
.holdemBtn:focus-visible,
.linkBtn:focus-visible,
.seatMenuBtn:focus-visible,
.seatSheetClose:focus-visible,
.pickerCard:focus-visible,
.actionBtn:focus-visible,
.raiseQuickBtn:focus-visible,
.actionUndo:focus-visible,
.toastAction:focus-visible,
.toastClose:focus-visible,
.potWizardChoice:focus-visible,
.cardSlot:focus-visible,
.subPanel summary:focus-visible,
.seat:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid #7fc4ff;
outline-offset: 2px;
/* The ring has to sit above the neighbouring seat that overlaps this one. */
position: relative;
z-index: 3;
}
/* A sheet with nothing focusable in it takes focus itself so its title is read out.
That shouldn't paint a ring around the whole panel. */
[role="dialog"]:focus,
[role="alertdialog"]:focus {
outline: none;
}
+242 -11
View File
@@ -9,6 +9,58 @@ body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* Same touch handling as the dealer table — see the note there. This page is the one
a dealer opens mid-hand to chop a pot, so the ~300ms the browser spends deciding
whether a tap was a double-tap-to-zoom is the last thing it needs. */
button,
[role="button"],
input,
select,
textarea,
a {
touch-action: manipulation;
}
button,
[role="button"],
input,
select,
textarea,
a {
-webkit-tap-highlight-color: transparent;
}
/* Not on the fields — a dealer typing a pot amount still needs to select it. */
button,
[role="button"] {
-webkit-user-select: none;
user-select: none;
}
.clearPotBtn:active:not(:disabled),
.choiceChip:active:not(:disabled),
.stepBtn:active:not(:disabled),
.splitBtn:active:not(:disabled),
.splitBtnGhost:active:not(:disabled),
.unevenFoldBtn:active:not(:disabled),
.unevenRemoveBtn:active:not(:disabled) {
transform: translateY(1px);
filter: brightness(1.25);
transition-duration: 0.04s;
}
@media (prefers-reduced-motion: reduce) {
.clearPotBtn:active:not(:disabled),
.choiceChip:active:not(:disabled),
.stepBtn:active:not(:disabled),
.splitBtn:active:not(:disabled),
.splitBtnGhost:active:not(:disabled),
.unevenFoldBtn:active:not(:disabled),
.unevenRemoveBtn:active:not(:disabled) {
transform: none;
}
}
#splitApp {
max-width: 480px;
margin: 0 auto;
@@ -45,6 +97,171 @@ body {
font-size: 0.85rem;
}
/* ---------- Mode toggle ---------- */
#modeToggle {
margin-bottom: 1.1rem;
}
/* ---------- Uneven amounts ---------- */
#unevenRows {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 0.6rem;
}
.unevenRow {
display: flex;
align-items: center;
gap: 0.45rem;
}
.unevenRowLabel {
flex: none;
width: 2rem;
color: #8f8f8f;
font-size: 0.8rem;
font-weight: 600;
}
.unevenAmountWrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.3rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 10px;
padding: 0 0.6rem;
}
.unevenAmountWrap:focus-within {
border-color: #d8b46a;
}
.unevenCurrency {
color: #777;
}
.unevenAmount {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-family: inherit;
font-size: 16px;
font-variant-numeric: tabular-nums;
padding: 0.6rem 0;
}
.unevenFoldBtn {
flex: none;
min-height: 44px;
padding: 0 0.6rem;
border-radius: 9px;
border: 1px solid #3a3a3a;
background: #232323;
color: #8f8f8f;
font-family: inherit;
font-size: 0.72rem;
cursor: pointer;
}
.unevenFoldBtn.selected {
background: linear-gradient(180deg, #3d1f1f, #2c1515);
border-color: #5b2626;
color: #ffd6d6;
}
.unevenRemoveBtn {
flex: none;
width: 30px;
height: 44px;
border: none;
background: none;
color: #6a6a6a;
font-size: 0.85rem;
cursor: pointer;
}
.unevenRemoveBtn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
#unevenResult {
background: linear-gradient(160deg, #1f2a21, #161c17);
border: 1px solid #2c3a2f;
border-radius: 14px;
padding: 0.9rem 1rem;
margin-bottom: 1.5rem;
}
#unevenResult[hidden] {
display: none;
}
.unevenPot {
display: flex;
align-items: baseline;
gap: 0.6rem;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.unevenPot + .unevenPot {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.unevenPotLabel {
flex: none;
color: #d8b46a;
font-weight: 600;
}
.unevenPotAmount {
flex: none;
color: #f2f2f2;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.unevenPotWho {
flex: 1;
min-width: 0;
text-align: right;
color: #9a9a9a;
font-size: 0.78rem;
}
.unevenPotReturn .unevenPotLabel,
.unevenPotReturn .unevenPotAmount {
color: #9a9a9a;
font-weight: 500;
}
.unevenPotTotal {
margin-top: 0.15rem;
border-top: 1px solid rgba(255, 255, 255, 0.14) !important;
}
.unevenPotTotal .unevenPotLabel {
color: #8f9d92;
font-weight: 500;
}
.unevenEmpty {
margin: 0;
color: #8f9d92;
font-size: 0.82rem;
line-height: 1.45;
}
/* ---------- The answer ---------- */
#splitResult {
@@ -104,6 +321,12 @@ body {
margin-bottom: 1.35rem;
}
/* A `display` declaration outranks the browser's own [hidden] rule, so anything
given one has to opt back out explicitly or toggling `hidden` does nothing. */
[hidden] {
display: none !important;
}
.splitLabel {
display: block;
margin-bottom: 0.5rem;
@@ -165,9 +388,11 @@ body {
cursor: pointer;
}
.clearPotBtn:hover {
background: #333;
color: #ddd;
@media (hover: hover) {
.clearPotBtn:hover {
background: #333;
color: #ddd;
}
}
/* ---------- Tap targets ---------- */
@@ -193,9 +418,11 @@ body {
transition: all 0.15s ease;
}
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
@media (hover: hover) {
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.choiceChip.selected {
@@ -230,8 +457,10 @@ body {
cursor: pointer;
}
.stepBtn:hover {
background: #333;
@media (hover: hover) {
.stepBtn:hover {
background: #333;
}
}
.waysReadout {
@@ -268,9 +497,11 @@ body {
transition: all 0.15s ease;
}
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
@media (hover: hover) {
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.splitBtnGhost {
@@ -11,9 +11,35 @@ const ActionBar = {
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
this.watchHeight();
this.render();
},
// The bar is fixed to the bottom of the screen, so everything else has to know how
// tall it is or the table simply runs underneath it — on a 640px-tall phone the
// bottom seats of the oval were sitting behind the Fold button. Its height isn't a
// constant either: opening the raise pad grows it. Publishing the measured height
// as a custom property lets the layout and the toast both stay clear of it without
// anyone guessing a number.
watchHeight() {
const host = document.getElementById("actionBar");
if (!host || this.heightWatcher) return;
const publish = () => {
const visible = !host.hidden;
const height = visible ? host.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
};
if (typeof ResizeObserver === "function") {
this.heightWatcher = new ResizeObserver(publish);
this.heightWatcher.observe(host);
}
window.addEventListener("resize", publish);
window.addEventListener("orientationchange", publish);
publish();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
isRunning() {
return Betting.liveSeats(this.state).length > 0;
@@ -27,6 +53,7 @@ const ActionBar = {
if (!this.isRunning()) {
host.hidden = true;
document.body.classList.remove("hasActionBar");
document.documentElement.style.setProperty("--actionBarHeight", "0px");
this.stopTankTimer();
this.tankSeatIndex = null;
return;
@@ -74,6 +101,16 @@ const ActionBar = {
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
row.appendChild(players);
// Nobody is being asked to act because nobody can — say so, or the bar just
// looks like it's waiting for something.
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
const runout = document.createElement("span");
runout.className = "actionRunout";
const stillIn = Betting.liveSeats(this.state).length;
runout.textContent = `All-in — running out the board (${stillIn} players)`;
row.appendChild(runout);
}
const index = this.state.actingSeatIndex;
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
const player = this.state.seats[index];
@@ -35,7 +35,7 @@ const AddChips = {
quickAmounts() {
const base = this.baseAmount();
return [
{ label: "½ buy-in", value: Math.round(base / 2 * 100) / 100 },
{ label: "½ buy-in", value: Money.round(base / 2) },
{ label: "Buy-in", value: base },
{ label: "2 buy-ins", value: base * 2 },
];
@@ -83,7 +83,7 @@ const AddChips = {
confirm() {
const amount = Math.max(0, Number(document.getElementById("addChipsInput").value) || 0);
this.close();
if (amount > 0) this.onConfirm(this.seatIndex, Math.round(amount * 100) / 100);
if (amount > 0) this.onConfirm(this.seatIndex, Money.round(amount));
},
};
+109 -16
View File
@@ -18,7 +18,7 @@ const Betting = {
},
round(amount) {
return Math.round((Number(amount) || 0) * 100) / 100;
return Money.round(amount);
},
// ---- Reading the table ----
@@ -39,11 +39,16 @@ const Betting = {
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
},
// Never below zero. A seat can't have negative chips in front of it, and a typo in
// the stack field used to come back as-is — which then flowed into putIn(), where
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
stackOf(state, player) {
const own = player.stack;
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
if (own !== null && own !== undefined && String(own).trim() !== "") {
return Math.max(0, this.round(own));
}
const fallback = Number(state.defaultStack);
return Number.isNaN(fallback) ? 0 : this.round(fallback);
return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
},
toCall(state, player) {
@@ -70,15 +75,28 @@ const Betting = {
for (let step = 1; step <= count; step++) {
const index = this.seatAt(state, fromIndex, step);
const player = state.seats[index];
if (player.inHand && !player.folded && this.stackOf(state, player) >= 0) return index;
if (player.inHand && !player.folded) return index;
}
return null;
},
seatAt(state, fromIndex, step) {
// Who the dealer asks next. A player who is all-in is still in the hand and still
// wins pots, but has no decision left, so the action passes over them.
nextToAct(state, fromIndex) {
const count = state.seatCount;
const delta = state.direction === "counterclockwise" ? -step : step;
return ((fromIndex + delta) % count + count) % count;
for (let step = 1; step <= count; step++) {
const index = this.seatAt(state, fromIndex, step);
const player = state.seats[index];
if (player.inHand && !player.folded && this.stackOf(state, player) > this.EPSILON_CHIPS) return index;
}
return null;
},
// Which chair sits `step` places along in the direction of play. The table owns
// this — walking the seats is exactly where the button, the blinds and the action
// used to drift apart from each other, so there is one implementation of it.
seatAt(state, fromIndex, step) {
return HoldemState.nextSeatIndex(state, fromIndex, step);
},
// A street is done when everyone still live has either matched the bet or has
@@ -156,7 +174,7 @@ const Betting = {
},
passAction(state, index) {
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextLiveIndex(state, index);
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextToAct(state, index);
},
// ---- Street and hand boundaries ----
@@ -184,15 +202,74 @@ const Betting = {
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
state.street = this.STREETS[nextStreetIndex] || "river";
state.actingSeatIndex = this.liveSeats(state).length > 1
? this.nextLiveIndex(state, state.dealerSeatIndex)
: null;
// It takes two players with chips to have a bet. Once everyone left is all-in
// the rest of the board is just dealt out.
if (!this.canStillBet(state)) {
state.actingSeatIndex = null;
return;
}
// After the flop the first live player left of the button acts — which heads-up
// is the big blind, since the button is the small blind. Naming the big blind
// directly also covers a button parked on a seat nobody is sitting in, and it
// still has to be someone with chips left to bet.
const { bbIndex, headsUp } = HoldemState.getBlindSeats(state);
const bigBlindCanAct =
headsUp &&
bbIndex !== null &&
!state.seats[bbIndex].folded &&
this.stackOf(state, state.seats[bbIndex]) > this.EPSILON_CHIPS;
state.actingSeatIndex = bigBlindCanAct
? bbIndex
: this.nextToAct(state, state.dealerSeatIndex);
},
isHandOver(state) {
return this.liveSeats(state).length <= 1;
},
// Players still in the hand who have chips left to put in. Fewer than two of them
// means the betting is finished even though the hand isn't.
seatsWithChips(state) {
return this.liveSeats(state).filter(s => this.stackOf(state, s.player) > this.EPSILON_CHIPS);
},
EPSILON_CHIPS: Money.EPSILON,
canStillBet(state) {
return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1;
},
// True when the hand is still live but nobody can bet again — the dealer is just
// running the board out.
isRunningOut(state) {
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
},
// True while nothing has happened in this hand except the blinds going in. Up to
// that point the dealer can still move the button or rename the blinds and have
// it cost nothing — which is the state a home game spends a lot of time in,
// arguing about whose turn it is before the cards come out.
onlyBlindsIn(state) {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
// Pulls the blinds back off the table and posts them again from wherever the
// button and the blind assignment now say they belong. Refunding first is what
// keeps the chip count straight when the button gets passed round three or four
// times before anybody is happy with it.
repostBlinds(state) {
state.seats.forEach(player => {
const back = this.committed(player);
if (back > 0) player.stack = this.round(this.stackOf(state, player) + back);
player.committed = 0;
player.hasActed = false;
});
this.beginHand(state);
},
// Deals a fresh betting round: blinds posted, action to the seat after the big
// blind, which is where a dealer expects to be looking when the cards go out.
beginHand(state) {
@@ -200,6 +277,11 @@ const Betting = {
state.currentBet = 0;
state.lastRaiseSize = 0;
// Position is read off the button all through the hand, so it has to be on
// somebody before the blinds are worked out — not on the empty seat 1 a
// brand-new table starts on.
HoldemState.ensureButtonOnPlayer(state);
state.seats.forEach(player => {
player.committed = 0;
player.hasActed = false;
@@ -207,15 +289,26 @@ const Betting = {
const sb = Number(state.smallBlind) || 0;
const bb = Number(state.bigBlind) || 0;
const sbIndex = this.seatAt(state, state.dealerSeatIndex, 1);
const bbIndex = this.seatAt(state, state.dealerSeatIndex, 2);
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
if (sb > 0 && state.seats[sbIndex].inHand) this.putIn(state, sbIndex, sb);
if (bb > 0 && state.seats[bbIndex].inHand) this.putIn(state, bbIndex, bb);
if (sbIndex === null || bbIndex === null) {
state.currentBet = 0;
state.actingSeatIndex = null;
return;
}
if (sb > 0) this.putIn(state, sbIndex, sb);
if (bb > 0) this.putIn(state, bbIndex, bb);
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
state.actingSeatIndex = this.nextLiveIndex(state, bbIndex);
// Heads-up the button is the small blind and acts first before the flop; with
// three or more it's the seat after the big blind. Either way, a blind big
// enough to put that player all-in leaves them nothing to decide.
state.actingSeatIndex = headsUp
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
: this.nextToAct(state, bbIndex);
},
// Winners get their share put back in front of them.
@@ -15,6 +15,10 @@ const HandHistoryExport = {
}
},
// Money columns go in as bare numbers. Written as "$1,250,000.00" they import as
// text, so the one thing anybody opens this file to do — total a column and see
// who owes what — doesn't work without cleaning every cell first. The commas were
// quoted correctly, so the file parsed; it just wouldn't add up.
toCsv(handHistory, standings = []) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
@@ -25,7 +29,7 @@ const HandHistoryExport = {
rows.push([
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.format(entry.pot),
Money.forSheet(entry.pot),
payouts,
]);
});
@@ -41,12 +45,12 @@ const HandHistoryExport = {
standings.forEach(row => {
rows.push([
row.name,
Money.format(row.in),
Money.format(row.won),
Money.formatSigned(row.net),
Money.format(row.boughtIn || 0),
Money.format(row.chipsNow || 0),
Money.formatSigned(row.cashPosition || 0),
Money.forSheet(row.in),
Money.forSheet(row.won),
Money.forSheet(row.net),
Money.forSheet(row.boughtIn || 0),
Money.forSheet(row.chipsNow || 0),
Money.forSheet(row.cashPosition || 0),
]);
});
}
+121 -28
View File
@@ -1,10 +1,27 @@
document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load();
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
// `hidden` attribute they all already toggle.
Modal.mount();
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
// by reference to every module at mount time, so it has to be refilled in place —
// reassigning the variable would leave the renderer and the betting bar pointing at
// the old object and quietly ignoring the undo.
const snapshotState = () => JSON.parse(JSON.stringify(state));
const restoreState = snapshot => {
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
HoldemState.save(state);
Render.renderAll();
};
// A gesture on a seat is a shortcut for something the dealer could do the long
// way, so each one is undoable the same way the header buttons are.
const seatAction = (index, message, mutate) => {
const snapshot = JSON.parse(JSON.stringify(state));
const snapshot = snapshotState();
mutate(state.seats[index], index);
HoldemState.save(state);
Render.renderAll();
@@ -12,10 +29,7 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, {
actionLabel: "Undo",
onAction: () => {
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
HoldemState.save(state);
Render.renderAll();
restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 });
},
duration: 6000,
@@ -42,6 +56,7 @@ document.addEventListener("DOMContentLoaded", () => {
},
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
onGiveButton: index => seatMenuHooks.makeDealer(index),
};
const seatMenuHooks = {
@@ -49,21 +64,79 @@ document.addEventListener("DOMContentLoaded", () => {
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
toggleDealtIn: index => {
const dealtIn = state.seats[index].inHand;
seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
const player = state.seats[index];
const dealtIn = player.inHand;
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
const alreadyIn = Betting.handTotal(player);
// Somebody walking away mid-hand doesn't get their chips back — they're
// dead money the winner collects. Say so, because the pot total pointedly
// does not go down and a dealer needs to know that's deliberate.
const message = dealtIn
? (alreadyIn > 0
? `${nameOf(index)} is out — their ${Money.format(alreadyIn)} stays in the pot.`
: `${nameOf(index)} sits out.`)
: (handRunning
? `${nameOf(index)} is in from the next hand.`
: `${nameOf(index)} is dealt in.`);
seatAction(index, message, p => {
p.inHand = !dealtIn;
if (!p.inHand) p.folded = false;
if (!p.inHand) {
// Their chips stay put; only the fold flag is cleared, since being
// out of the hand already covers not winning it.
p.folded = false;
if (state.actingSeatIndex === index) Betting.passAction(state, index);
} else if (handRunning) {
// Joining a hand already in progress means sitting this one out —
// you can't buy into a pot you haven't been paying for. `folded`
// is exactly that state, and the next hand clears it.
p.folded = true;
}
});
},
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
// Before anyone has acted the blinds are still just sitting there, so
// moving the button moves them too — a home game passes it round several
// times before the cards come out, and the posted chips have to follow.
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
}),
assignBlind: (index, which) => {
const blinds = HoldemState.getBlindSeats(state);
const other = which === "sb" ? blinds.bbIndex : blinds.sbIndex;
// Naming one blind pins the other where it already is, so the dealer only
// has to state the part they're changing. If that would land both on one
// player, the other blind moves to the next seat along.
let partner = other !== null && other !== undefined && other !== index
? other
: Betting.nextLiveIndex(state, index);
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
const label = which === "sb" ? "small blind" : "big blind";
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
state.blindOverride = which === "sb"
? { sbIndex: index, bbIndex: partner }
: { sbIndex: partner, bbIndex: index };
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
});
},
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
state.blindOverride = null;
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
}),
editSeat: index => Render.toggleSeatEditor(index),
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
// Chips in front and cash in are different questions — the stack moves
// every hand, what they've handed over only moves when they buy.
player.stack = Math.round((Betting.stackOf(state, player) + amount) * 100) / 100;
player.buyIns = Math.round(((Number(player.buyIns) || 0) + amount) * 100) / 100;
player.stack = Money.round(Betting.stackOf(state, player) + amount);
player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
});
}),
};
@@ -104,7 +177,7 @@ document.addEventListener("DOMContentLoaded", () => {
resolvePot: openPotWizard,
beginAction: label => {
actionHistory.push({ label, snapshot: JSON.parse(JSON.stringify(state)) });
actionHistory.push({ label, snapshot: snapshotState() });
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
},
@@ -113,10 +186,7 @@ document.addEventListener("DOMContentLoaded", () => {
undoLast: () => {
const step = actionHistory.pop();
if (!step) return;
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, step.snapshot);
hooks.save(state);
Render.renderAll();
restoreState(step.snapshot);
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
},
});
@@ -163,7 +233,7 @@ document.addEventListener("DOMContentLoaded", () => {
// would notice a misclick. The snapshot is deliberately in-memory only: an
// undo is for the mistake you just made, not one from last night's session.
const runUndoable = (message, mutate) => {
const snapshot = JSON.parse(JSON.stringify(state));
const snapshot = snapshotState();
mutate();
// Starting or settling a hand invalidates the per-action history: those
// snapshots describe a hand that's over. The whole-step Undo below replaces it.
@@ -174,19 +244,44 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, {
actionLabel: "Undo",
onAction: () => {
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
hooks.save(state);
Render.renderAll();
restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 });
},
});
};
document.getElementById("seatCountInput").addEventListener("change", e => {
HoldemState.resizeSeats(state, Number(e.target.value));
hooks.save(state);
Render.renderAll();
// A seat that has anything of a player in it — a name, chips, cash in, or a hand
// in progress. Shrinking the table throws these away permanently, and it also
// orphans them in the session standings, where they keep their winnings but lose
// the stack and buy-in the settle-up is worked out from.
const seatHasSomeoneIn = player =>
!!(player.name && player.name.trim()) ||
player.inHand ||
(Number(player.stack) || 0) > 0 ||
(Number(player.buyIns) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0;
document.getElementById("seatCountInput").addEventListener("change", async e => {
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
const losing = state.seats.slice(requested).filter(seatHasSomeoneIn);
if (losing.length > 0) {
const who = losing
.map((p, i) => HoldemState.displayName(p, requested + state.seats.slice(requested).indexOf(p)))
.join(", ");
const ok = await Dialog.confirm({
title: `Remove ${losing.length} ${losing.length === 1 ? "player" : "players"}?`,
message: `Going down to ${requested} seats takes ${who} off the table along with their chips and buy-ins, and their session totals stop adding up.`,
confirmLabel: "Remove them",
danger: true,
});
if (!ok) {
e.target.value = state.seatCount;
return;
}
}
runUndoable(`Table set to ${requested} seats.`, () => HoldemState.resizeSeats(state, requested));
});
document.getElementById("directionSelect").addEventListener("change", e => {
@@ -278,6 +373,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (e.key === "Enter") AddChips.confirm();
});
// 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; });
@@ -285,9 +381,6 @@ document.addEventListener("DOMContentLoaded", () => {
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
});
document.addEventListener("keydown", e => {
if (e.key === "Escape" && !rulingsOverlay.hidden) closeRulings();
});
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
@@ -0,0 +1,147 @@
// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
//
// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
// confirm — opened and closed by five different modules, and each one was missing
// the same three things: focus never moved into the dialog, Tab walked straight out
// of it into the page behind the scrim, and Escape did nothing (only the rulings
// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
// 1 of 5 closed on Escape.
//
// Rather than patch that into five modules, this watches the `hidden` attribute every
// overlay already toggles. Nothing else had to change, and an overlay added later
// gets the same behaviour without anyone remembering to wire it up — which is the
// failure this is fixing in the first place.
const Modal = {
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
// must not be a Tab stop.
FOCUSABLE: [
"a[href]",
"button:not([disabled])",
'input:not([disabled]):not([type="hidden"])',
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(","),
open: [],
mount() {
document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
// Capture phase: the dialog gets first refusal on Escape and Tab, before any
// table-level shortcut sees them.
document.addEventListener("keydown", e => this.onKeyDown(e), true);
},
watch(overlay) {
const sync = () => {
const isOpen = !overlay.hidden;
const known = this.open.some(entry => entry.overlay === overlay);
if (isOpen && !known) this.onOpened(overlay);
else if (!isOpen && known) this.onClosed(overlay);
};
new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
sync();
},
sheetOf(overlay) {
return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
},
focusableIn(overlay) {
return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
if (el.closest("[hidden]")) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
},
onOpened(overlay) {
const sheet = this.sheetOf(overlay);
// aria-modal is only true while the thing is actually open, so it's set here
// rather than baked into the markup.
if (sheet) sheet.setAttribute("aria-modal", "true");
this.open.push({ overlay, returnFocus: document.activeElement });
// Focus the first real control. A dialog that leaves focus on the button
// behind it reads, to a screen reader, as though nothing happened at all.
// Content-only sheets get focus on the sheet so the title is announced.
const focusable = this.focusableIn(overlay);
const target = focusable[0];
if (target) {
target.focus({ preventScroll: true });
} else if (sheet) {
if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
sheet.focus({ preventScroll: true });
}
},
onClosed(overlay) {
const index = this.open.findIndex(entry => entry.overlay === overlay);
if (index === -1) return;
const [entry] = this.open.splice(index, 1);
const sheet = this.sheetOf(overlay);
if (sheet) sheet.removeAttribute("aria-modal");
// Put the dealer back where they were. Without this, closing the pot wizard
// drops focus to the top of the document and the next Tab starts from the
// page header.
const back = entry.returnFocus;
if (back && back.isConnected && typeof back.focus === "function") {
back.focus({ preventScroll: true });
}
},
topmost() {
return this.open.length ? this.open[this.open.length - 1] : null;
},
// Escape presses the dialog's own cancel/close button rather than just hiding the
// overlay, so whatever that button does — resolving the confirm promise, clearing
// the add-chips draft — still happens. Only if there isn't one does it hide the
// overlay directly.
dismiss(overlay) {
const closer = overlay.querySelector("[data-modal-close]");
if (closer) closer.click();
else overlay.hidden = true;
},
onKeyDown(event) {
const top = this.topmost();
if (!top) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.dismiss(top.overlay);
return;
}
if (event.key !== "Tab") return;
// Keep Tab inside the dialog. Everything behind the scrim is unreachable by
// mouse, so letting the keyboard wander back there is a trap with no way out.
const focusable = this.focusableIn(top.overlay);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
event.preventDefault();
last.focus({ preventScroll: true });
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
event.preventDefault();
first.focus({ preventScroll: true });
}
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = Modal;
}
@@ -1,10 +1,51 @@
// Shared money formatting for every casino surface. Kept apart from the table's
// rendering so the quick-split calculator can pull in the formatter without dragging
// the whole seat/chip UI along with it.
// Shared money handling for every casino surface. Kept apart from the table's
// rendering so the quick-split calculator can pull it in without dragging the whole
// seat/chip UI along with it.
//
// Everything the dealer touches is money, and money is decimal, so every module used
// to carry its own `Math.round(x * 100) / 100`. That's one rounding rule written out
// sixteen times in six files, which is sixteen chances for one of them to drift.
// It lives here now, and the modules that do arithmetic call these instead.
const Money = {
// Half a cent. Chip comparisons need a tolerance: a stack that lands on
// 0.0000000001 after a split is empty as far as a dealer is concerned, and an
// exact `> 0` would treat that player as still having chips to bet.
EPSILON: 0.004,
// All money maths goes through cents, so 0.1 + 0.2 never reaches a payout.
toCents(amount) {
return Math.round((Number(amount) || 0) * 100);
},
fromCents(cents) {
return cents / 100;
},
round(amount) {
return this.toCents(amount) / 100;
},
// Cents only when there are cents.
//
// A chip game deals in whole numbers nearly all the time, and a trailing ".00"
// is two characters that say nothing — measured at ~22px, against the 64px a
// seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps;
// "$1,250,000" does. Amounts that genuinely carry cents still show them, so a
// 25c-blind game reads exactly as it did.
format(amount) {
const value = Number(amount) || 0;
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const hasCents = this.toCents(value) % 100 !== 0;
return `$${value.toLocaleString(undefined, {
minimumFractionDigits: hasCents ? 2 : 0,
maximumFractionDigits: 2,
})}`;
},
// The same amount for a spreadsheet rather than for a person: no currency symbol
// and no thousands separators, because those are what make a column of money
// import as text and refuse to add up.
forSheet(amount) {
return (this.toCents(amount) / 100).toFixed(2);
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
@@ -4,7 +4,7 @@
// A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount
// that player put in the pot and is whittled down round by round as money gets paid out.
const PotCalculator = {
EPSILON: 0.004,
EPSILON: Money.EPSILON,
// Builds the initial entry list from raw contributions, keeping folded players in
// (their chips are still "dead money" owed to whoever wins) while excluding anyone
@@ -33,24 +33,39 @@ const PotCalculator = {
// to their own contribution, and only players who reached that height can win it.
// Folded money stays in — it's dead money owed to whoever wins the layer it fell in.
layers(players) {
const live = players.filter(p => p.contribution > this.EPSILON);
// Uncalled chips were never played for, so they come off the top before any
// pot is built. Deriving the pots from what's left is what keeps this function
// and uncalledReturns() from disagreeing about the same money — they used to
// decide "uncontested" separately, and an overbet that its own bettor was
// still live for got announced as a side pot only they could win.
const returned = new Map(this.uncalledReturns(players).map(r => [r.id, Money.toCents(r.amount)]));
const live = players
.map(p => ({ ...p, cents: Money.toCents(p.contribution) - (returned.get(p.id) || 0) }))
.filter(p => p.cents > 0);
if (live.length === 0) return [];
const heights = [...new Set(live.map(p => Math.round(p.contribution * 100)))].sort((a, b) => a - b);
const heights = [...new Set(live.map(p => p.cents))].sort((a, b) => a - b);
const layers = [];
let floor = 0;
heights.forEach(height => {
const band = height - floor;
const payers = live.filter(p => Math.round(p.contribution * 100) >= height);
const payers = live.filter(p => p.cents >= height);
const amount = band * payers.length;
const eligible = payers.filter(p => !p.folded).map(p => p.id);
// A band nobody can win isn't a pot — it's an overbet that never got
// called, and it goes back to whoever put it in. Announcing it as a side
// pot would overstate what's actually being played for.
if (amount > 0 && eligible.length > 0) {
layers.push({ amount: amount / 100, eligible });
if (amount > 0) {
if (eligible.length > 0) {
layers.push({ amount: Money.fromCents(amount), eligible });
} else if (layers.length > 0) {
// Everyone who reached this height folded, so it isn't a pot of its
// own — it's dead money owed to whoever wins the layer underneath.
// Dropping it would quietly shrink the announced pot.
const below = layers[layers.length - 1];
below.amount = Money.round(below.amount + Money.fromCents(amount));
}
// With no layer underneath either, nobody in the hand is live at all.
// That's a fold flag the dealer needs to fix, not a payout to guess at.
}
floor = height;
});
@@ -66,7 +81,7 @@ const PotCalculator = {
previous.eligible.every(id => layer.eligible.includes(id));
if (sameEligibility) {
previous.amount = Math.round((previous.amount + layer.amount) * 100) / 100;
previous.amount = Money.round(previous.amount + layer.amount);
} else {
merged.push({ ...layer });
}
@@ -79,21 +94,28 @@ const PotCalculator = {
},
// Chips nobody could call, which the dealer pushes back rather than into the pot.
// Anything above the biggest live contribution was never contested — most often an
// overbet by someone who then folded.
//
// A bet is only called up to what somebody *else* was willing to put in, so the
// ceiling for each player is the largest contribution among everyone but them.
// Measuring against the whole table instead — including the bettor's own chips —
// makes the biggest bet its own yardstick, so the one case this exists for (you
// shove, a short stack calls for less) comes back empty.
uncalledReturns(players) {
const contributing = players.filter(p => p.contribution > this.EPSILON);
const liveCents = contributing.filter(p => !p.folded).map(p => Math.round(p.contribution * 100));
// Nobody live at all means the fold flags are wrong, not that every chip on
// the table was uncalled. That's a correction for the dealer to make, so say
// nothing here and let the wizard ask them to fix it.
if (liveCents.length === 0) return [];
if (contributing.filter(p => !p.folded).length === 0) return [];
const cap = Math.max(...liveCents);
const cents = contributing.map(p => Money.toCents(p.contribution));
return contributing
.map(p => ({ id: p.id, amount: (Math.round(p.contribution * 100) - cap) / 100 }))
.map((p, index) => {
const others = cents.filter((_, i) => i !== index);
const cap = others.length > 0 ? Math.max(...others) : 0;
return { id: p.id, amount: Money.fromCents(cents[index] - cap) };
})
.filter(r => r.amount > this.EPSILON);
},
@@ -113,12 +135,12 @@ const PotCalculator = {
if (e.remaining <= this.EPSILON) return e;
const used = Math.min(e.remaining, cap);
payoutPool += used;
return { ...e, remaining: Math.round((e.remaining - used) * 100) / 100 };
return { ...e, remaining: Money.round(e.remaining - used) };
});
payoutPool = Math.round(payoutPool * 100) / 100;
const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length;
payoutPool = Money.round(payoutPool);
const baseShareCents = Math.floor(Money.toCents(payoutPool) / winners.length);
let leftoverCents = Money.toCents(payoutPool) - baseShareCents * winners.length;
const perWinner = winnerIds
.filter(id => winners.some(w => w.id === id))
@@ -133,7 +155,7 @@ const PotCalculator = {
i++;
}
const result = perWinner.map(w => ({ id: w.id, amount: w.amountCents / 100 }));
const result = perWinner.map(w => ({ id: w.id, amount: Money.fromCents(w.amountCents) }));
const remainingContenders = this.getContenders(updatedEntries);
return {
@@ -19,10 +19,21 @@ const PotWizard = {
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
);
this.seatIndexById = new Map(players.map(p => [p.id, state.seats.indexOf(p)]));
// The label carries name and cards together for the log and the payout lines;
// the choice rows want them separately so each can be styled and read on its own.
this.nameById = new Map(
players.map(p => [p.id, HoldemState.displayName(p, state.seats.indexOf(p))])
);
this.cardsById = new Map(
players.map(p => [p.id, (p.cards || []).filter(Boolean)])
);
const contributions = players.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
folded: p.folded,
// A seat switched off mid-hand still has its chips in the pot, but it
// can no more win them than a folded one can — same dead money, and the
// wizard must not offer it as a winner.
folded: p.folded || HoldemState.isDeadMoney(p),
}));
this.entries = PotCalculator.buildEntries(contributions);
// Kept so the wizard can show the whole breakdown up front, not just the layer
@@ -120,13 +131,46 @@ const PotWizard = {
contenders.forEach(c => {
const label = document.createElement("label");
label.className = "potWizardChoice";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = c.id;
const text = document.createElement("span");
text.textContent = `${this.playerLabelById.get(c.id)} — up to ${Money.format(c.remaining)}`;
// The row is the target, not the box — so the row has to look chosen too.
checkbox.addEventListener("change", () => {
label.classList.toggle("chosen", checkbox.checked);
});
label.appendChild(checkbox);
const text = document.createElement("span");
text.className = "potWizardChoiceText";
const name = document.createElement("span");
name.className = "potWizardChoiceName";
name.textContent = this.nameById.get(c.id);
text.appendChild(name);
const upTo = document.createElement("span");
upTo.className = "potWizardChoiceUpTo";
upTo.textContent = `up to ${Money.format(c.remaining)}`;
text.appendChild(upTo);
label.appendChild(text);
// Cards sit apart from the name rather than inside it, so a dealer reading
// a row across the table sees the hand, not a sentence.
const cards = this.cardsById.get(c.id) || [];
if (cards.length > 0) {
const cardsEl = document.createElement("span");
cardsEl.className = "potWizardChoiceCards";
cards.forEach((card, i) => {
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
const span = document.createElement("span");
if (/[HD]$/i.test(card)) span.className = "cardRed";
span.textContent = Render.formatCard(card);
cardsEl.appendChild(span);
});
label.appendChild(cardsEl);
}
choices.appendChild(label);
});
round.appendChild(choices);
@@ -15,12 +15,28 @@ const QuickSplit = {
ways: 2,
chip: 1,
// "even" is the common case — one pot, everyone matched. "uneven" is the one this
// screen used to send people back to the full table for: somebody all-in for less,
// so the money splits into a main pot and side pots.
mode: "even",
entries: [],
mount() {
this.potInput = document.getElementById("potInput");
this.clearPotBtn = document.getElementById("clearPotBtn");
this.entries = [{ amount: "", folded: false }, { amount: "", folded: false }, { amount: "", folded: false }];
this.renderWayChoices();
this.renderChipChoices();
this.renderUnevenRows();
document.getElementById("modeEvenBtn").addEventListener("click", () => this.setMode("even"));
document.getElementById("modeUnevenBtn").addEventListener("click", () => this.setMode("uneven"));
document.getElementById("addPlayerBtn").addEventListener("click", () => {
this.entries.push({ amount: "", folded: false });
this.renderUnevenRows();
this.update();
});
this.potInput.addEventListener("input", () => {
this.sanitizePotInput();
@@ -127,7 +143,112 @@ const QuickSplit = {
});
},
setMode(mode) {
this.mode = mode;
document.getElementById("modeEvenBtn").classList.toggle("selected", mode === "even");
document.getElementById("modeUnevenBtn").classList.toggle("selected", mode === "uneven");
document.querySelectorAll(".evenOnly").forEach(el => { el.hidden = mode !== "even"; });
document.querySelectorAll(".unevenOnly").forEach(el => { el.hidden = mode !== "uneven"; });
document.getElementById("splitResult").hidden = mode !== "even";
document.getElementById("unevenResult").hidden = mode !== "uneven";
this.update();
},
renderUnevenRows() {
const host = document.getElementById("unevenRows");
const template = document.getElementById("unevenRowTemplate");
host.innerHTML = "";
this.entries.forEach((entry, index) => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".unevenRowLabel").textContent = `P${index + 1}`;
const amount = row.querySelector(".unevenAmount");
amount.value = entry.amount;
amount.setAttribute("aria-label", `Player ${index + 1} amount`);
amount.addEventListener("input", () => {
entry.amount = amount.value.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1");
if (entry.amount !== amount.value) amount.value = entry.amount;
this.update();
});
// Folded money stays in the pot as dead money, but its owner can't win any
// of it — which is exactly the distinction that makes side pots tricky.
const fold = row.querySelector(".unevenFoldBtn");
fold.classList.toggle("selected", entry.folded);
fold.setAttribute("aria-pressed", String(entry.folded));
fold.addEventListener("click", () => {
entry.folded = !entry.folded;
this.renderUnevenRows();
this.update();
});
const remove = row.querySelector(".unevenRemoveBtn");
remove.disabled = this.entries.length <= 2;
remove.addEventListener("click", () => {
this.entries.splice(index, 1);
this.renderUnevenRows();
this.update();
});
host.appendChild(row);
});
},
updateUneven() {
const players = this.entries
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
.filter(p => p.contribution > 0);
const pots = PotCalculator.layers(players);
const returns = PotCalculator.uncalledReturns(players);
const host = document.getElementById("unevenPots");
const empty = document.getElementById("unevenEmpty");
host.innerHTML = "";
if (pots.length === 0 && returns.length === 0) {
empty.hidden = false;
empty.textContent = players.length === 0
? "Enter what each player put in."
: "Nobody left who can win — un-fold whoever should take it.";
return;
}
empty.hidden = true;
const addRow = (label, amount, who, className) => {
const row = document.createElement("div");
row.className = `unevenPot ${className || ""}`;
const name = document.createElement("span");
name.className = "unevenPotLabel";
name.textContent = label;
const value = document.createElement("span");
value.className = "unevenPotAmount";
value.textContent = Money.format(amount);
const eligible = document.createElement("span");
eligible.className = "unevenPotWho";
eligible.textContent = who;
row.appendChild(name);
row.appendChild(value);
row.appendChild(eligible);
host.appendChild(row);
};
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
const total = players.reduce((sum, p) => sum + p.contribution, 0);
addRow("On the table", Money.round(total), "", "unevenPotTotal");
},
update() {
if (this.mode === "uneven") {
this.updateUneven();
return;
}
const potValue = Number(this.potInput.value);
const pot = Number.isNaN(potValue) ? 0 : potValue;
this.clearPotBtn.hidden = this.potInput.value === "";
@@ -158,7 +158,7 @@ const Render = {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state);
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
this.state.seats.forEach((player, index) => {
const node = template.content.firstElementChild.cloneNode(true);
@@ -172,15 +172,26 @@ const Render = {
const dealerChip = node.querySelector(".dealerChip");
dealerChip.classList.toggle("active", index === dealerIndex);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Tapping the puck is the same act as "Give the button" in the seat menu,
// so it goes through the same hook — otherwise this one path would move
// the button without moving the blinds that follow it.
dealerChip.addEventListener("click", e => {
e.stopPropagation();
this.state.dealerSeatIndex = index;
this.hooks.save(this.state);
this.renderSeats();
this.hooks.onGiveButton(index);
});
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
const sbBadge = node.querySelector(".sbBadge");
const bbBadge = node.querySelector(".bbBadge");
sbBadge.classList.toggle("visible", index === sbIndex);
bbBadge.classList.toggle("visible", index === bbIndex);
// A blind the dealer named by hand isn't where the button would have put
// it, so it's marked — otherwise the table looks like it's just wrong.
// The `!!` matters: getBlindSeats leaves `manual` undefined when the
// blinds are positional, and classList.toggle with an undefined second
// argument *flips* the class instead of clearing it, so the marker
// appeared and disappeared on alternate renders.
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
@@ -250,11 +261,14 @@ const Render = {
const total = handTotal > 0
? handTotal
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
const totalEl = node.querySelector(".seatPuckTotal");
totalEl.textContent = total > 0 ? Money.format(total) : "";
this.fitAmount(totalEl);
const stackEl = node.querySelector(".seatPuckStack");
const stack = Betting.stackOf(this.state, player);
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
this.fitAmount(stackEl);
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
node.classList.toggle("seatEmpty", empty);
@@ -263,6 +277,17 @@ const Render = {
node.classList.toggle("seatLive", player.inHand && !player.folded);
},
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
// because the number has no spaces to break on, it doesn't wrap or ellipsise
// either — it just draws straight out over the neighbouring seat. Stepping the
// font down keeps every digit, which matters: a dealer misreading a stack is a
// worse outcome than a small one, so this shrinks rather than abbreviating.
fitAmount(el) {
const length = (el.textContent || "").length;
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
el.classList.toggle("puckAmountHuge", length >= 10);
},
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
// (which live in a separate panel) never lose focus.
refreshPuck(index) {
@@ -45,6 +45,24 @@ const SeatMenu = {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
// Naming a blind by hand. Only offered while nothing but the blinds are in —
// once somebody has called, moving a blind would mean unpicking a bet that
// has already been answered, and that's a decision for the dealer's mouth,
// not a menu item. Which blind a seat already has is shown rather than
// offered again.
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
const blinds = HoldemState.getBlindSeats(this.state);
if (blinds.sbIndex !== index) {
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
}
if (blinds.bbIndex !== index) {
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
}
if (this.state.blindOverride) {
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
}
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
@@ -32,14 +32,12 @@ const Shortcuts = {
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
},
// An overlay owns the keyboard while it's up: the confirm dialog already binds
// Enter and Escape, and the card picker and seat menu are modal.
// An overlay owns the keyboard while it's up. Asking the document which overlays
// are open, rather than listing them, means a sheet added later can't quietly
// start letting "f" fold the table behind it — the same reason Modal watches for
// them instead of being told about each one.
overlayOpen() {
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
.some(id => {
const el = document.getElementById(id);
return el && !el.hidden;
});
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
},
onKey(e) {
@@ -7,7 +7,7 @@
const SplitCalculator = {
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
toCents(amount) {
return Math.round((Number(amount) || 0) * 100);
return Money.toCents(amount);
},
split(potAmount, ways, chipAmount) {
@@ -40,11 +40,11 @@ const SplitCalculator = {
// Sanity check for the UI: what the split actually hands out has to equal the pot.
accountedFor(result) {
return (
Math.round(result.each * 100) * result.players +
Math.round(result.chip * 100) * result.oddChips +
Math.round(result.dust * 100)
) / 100;
return Money.fromCents(
Money.toCents(result.each) * result.players +
Money.toCents(result.chip) * result.oddChips +
Money.toCents(result.dust)
);
},
};
+121 -12
View File
@@ -58,6 +58,12 @@ const HoldemState = {
currentBet: 0,
lastRaiseSize: 0,
actingSeatIndex: null,
// Set when the dealer names the blinds by hand instead of letting them
// follow the button — { sbIndex, bbIndex }. Home games do this constantly
// ("you had it last time", someone arriving late owing a blind), and the
// button order is the wrong answer often enough that it has to be
// overridable. Cleared at the start of every hand.
blindOverride: null,
};
},
@@ -73,7 +79,8 @@ const HoldemState = {
// 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"]
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
"actingSeatIndex", "blindOverride"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
@@ -124,9 +131,14 @@ const HoldemState = {
},
// Contribution the table calculator will treat this player as having put in.
//
// Chips already pushed into the middle count whether or not the player is still
// in the hand. Someone who storms off, gets taken out by the dealer, or is
// dealt out mid-hand leaves their money on the felt — it's dead money owed to
// whoever wins, and it is not theirs to take back. Reading `inHand` first is
// how those chips used to disappear out of the pot the instant a seat was
// switched off, quietly shrinking the payout by whatever they had posted.
computeContribution(player, chipLegend, basePotAmount) {
if (!player.inHand) return 0;
const override = player.amountOverride;
if (override !== null && override !== undefined && String(override).trim() !== "") {
const parsed = Number(override);
@@ -139,10 +151,22 @@ const HoldemState = {
}, 0);
if (chipTotal > 0) return chipTotal;
// The table-wide default is the one thing that stays tied to being dealt in:
// it's a stand-in for "everyone put the same amount in", so applying it to
// empty chairs would invent a contribution from every seat nobody is using.
if (!player.inHand) return 0;
const base = Number(basePotAmount);
return Number.isNaN(base) ? 0 : Math.max(0, base);
},
// Anyone whose chips are in the pot, whether or not they're still in the hand.
// A seat that's been switched off mid-hand is dead money: it counts towards the
// pot but can never be picked as a winner, exactly like a fold.
isDeadMoney(player) {
return !player.inHand;
},
totalPot(state) {
return state.seats.reduce(
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
@@ -159,18 +183,101 @@ const HoldemState = {
return ((fromIndex + delta) % count + count) % count;
},
// The button passes to the next player, not the next chair. Moving it one seat at
// a time parks it on empty chairs — with four players spread around ten seats it
// 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.
advanceButton(state) {
for (let step = 1; step <= state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) {
state.dealerSeatIndex = index;
return;
}
}
// Nobody is dealt in; move it along anyway so it doesn't look stuck.
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
},
// Seats dealt into this hand, in blind order starting from the seat after the
// button. Blinds follow the players, not the chairs — the seats immediately left
// of the button are empty often enough that counting chairs posts them to nobody.
liveOrderFromButton(state) {
const order = [];
for (let step = 1; step <= state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) order.push(index);
}
return order;
},
// A button sitting on an empty chair is only cosmetic until you try to read
// position off it. On the very first hand of a fresh table it starts on seat 1
// whether or not anybody is sitting there, and a table that empties out around it
// leaves it stranded. Move it to a real player before a hand is dealt.
ensureButtonOnPlayer(state) {
if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return;
for (let step = 0; step < state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) {
state.dealerSeatIndex = index;
return;
}
}
},
// A hand-picked blind is only usable if that seat is actually in the hand — a
// dealer who names a blind and then deals that player out shouldn't have the
// table quietly post to nobody.
validBlindOverride(state) {
const o = state.blindOverride;
if (!o) return null;
const ok = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i] && state.seats[i].inHand;
if (!ok(o.sbIndex) || !ok(o.bbIndex) || o.sbIndex === o.bbIndex) return null;
return o;
},
// 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) {
const sbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
const bbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 2);
return { dealerIndex: state.dealerSeatIndex, sbIndex, bbIndex };
const live = this.liveOrderFromButton(state);
const dealerIndex = state.dealerSeatIndex;
// A blind the dealer named by hand wins over anything positional. Heads-up is
// still reported honestly so the betting order stays right — who posts what
// is the dealer's call, but two-handed play acting in reverse is a rule.
const override = this.validBlindOverride(state);
if (override) {
return {
dealerIndex,
sbIndex: override.sbIndex,
bbIndex: override.bbIndex,
headsUp: live.length === 2,
manual: true,
};
}
if (live.length < 2) {
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
}
if (live.length === 2) {
const buttonIsPlaying = state.seats[dealerIndex].inHand;
const sbIndex = buttonIsPlaying ? dealerIndex : live[0];
const bbIndex = live.find(i => i !== sbIndex);
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
}
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
},
startNextHand(state) {
this.advanceButton(state);
// A hand-picked blind applies to the hand it was picked for. Carrying it into
// 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;
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
@@ -179,6 +286,8 @@ const HoldemState = {
p.committed = 0;
p.hasActed = false;
});
// Anyone who joined mid-hand is a full player again from here.
this.ensureButtonOnPlayer(state);
},
// Appends a resolved-pot record to the running session log a dealer can point to
@@ -246,8 +355,8 @@ const HoldemState = {
return Array.from(totals.values())
.map(t => {
const seat = seatById.get(t.id);
const boughtIn = seat ? Math.round((Number(seat.buyIns) || 0) * 100) / 100 : 0;
const chipsNow = seat ? Math.round((Number(seat.stack) || 0) * 100) / 100 : 0;
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
// Chips already pushed into the hand in progress are still this
// player's money until it's won. Leaving them out makes the cash
// position disagree with the hand log for as long as a hand is open —
@@ -257,12 +366,12 @@ const HoldemState = {
return {
...t,
in: Math.round(t.in * 100) / 100,
won: Math.round(t.won * 100) / 100,
net: Math.round((t.won - t.in) * 100) / 100,
in: Money.round(t.in),
won: Money.round(t.won),
net: Money.round(t.won - t.in),
boughtIn,
chipsNow,
cashPosition: Math.round((chipsNow + inFrontOfThem - boughtIn) * 100) / 100,
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
};
})
.sort((a, b) => b.net - a.net);
+43
View File
@@ -0,0 +1,43 @@
# Hold'em dealer tests
Unit tests for the money logic behind `/Casino/TexasHoldem` — the betting engine, the
side-pot calculator, the split calculator and the table state.
```bash
node --test tests/holdem/*.test.js
```
No install step, no `package.json`, no dependencies. The browser modules are plain
script-tag files that each end with a `module.exports` guard, so Node can require them
as-is; `load.js` puts them on `globalThis` under the same names they use to find each
other in the page, in the load order from `TexasHoldem.cshtml`.
## What's covered
| File | Covers |
| --- | --- |
| `potCalculator.test.js` | Main and side pots, dead money, uncalled bets, odd-cent distribution |
| `betting.test.js` | Blinds, street completion, raises reopening the action, all-in run-outs |
| `state.test.js` | Button rotation, which seats post blinds, session standings |
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
| `invariants.test.js` | 1000 randomised hands per property (see below) |
## Why the randomised suite exists
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
to empty chairs, the button skipping past players, heads-up acting out of order, an
overbet announced as a side pot. None of them show up on a full six-handed table with
equal stacks, which is the table anyone writes a fixed example for.
`invariants.test.js` deals a thousand hands across random seat counts, random empty
chairs, random stack depths and random dealer input, and checks the things a dealer
would notice being wrong:
- chips are never created or destroyed during play
- the pot breakdown plus the uncalled returns always equal the money that went in
- paying the pot out settles it to the cent
- once uncalled chips are handed back, nobody is still betting above the whole table
- the button and both blinds always land on players who are actually in the hand
The generator is seeded, so a failure reproduces from the seed printed in the message
rather than being a flake to re-run.
+198
View File
@@ -0,0 +1,198 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, makeTable, chipsOnTable } = require("./load");
describe("Betting.beginHand", () => {
test("blinds are posted and the seat after the big blind acts first", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0, smallBlind: 1, bigBlind: 2 });
Betting.beginHand(state);
assert.equal(state.seats[1].committed, 1, "small blind");
assert.equal(state.seats[2].committed, 2, "big blind");
assert.equal(state.currentBet, 2);
assert.equal(state.actingSeatIndex, 3, "under the gun");
});
test("heads-up, the button posts the small blind and acts first pre-flop", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
Betting.beginHand(state);
assert.equal(state.seats[2].committed, 1, "the button is the small blind");
assert.equal(state.seats[5].committed, 2);
assert.equal(state.actingSeatIndex, 2, "the small blind acts first pre-flop heads-up");
});
test("heads-up, the big blind acts first once the flop is out", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
Betting.beginHand(state);
Betting.call(state, 2);
Betting.check(state, 5);
Betting.closeStreet(state);
assert.equal(state.street, "flop");
assert.equal(state.actingSeatIndex, 5, "the big blind is first to act after the flop");
});
test("a blind bigger than a stack puts that player all-in without stalling the action", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0, smallBlind: 50, bigBlind: 100 });
state.seats[0].stack = 10; // can't cover the small blind
Betting.beginHand(state);
assert.equal(state.seats[0].stack, 0, "all-in for what they had");
assert.equal(state.seats[0].committed, 10);
assert.notEqual(state.actingSeatIndex, 0, "a player with no chips has nothing to decide");
});
test("posting blinds never invents or destroys chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before);
});
});
describe("Betting street completion", () => {
test("a street ends once everyone has matched the bet and had a turn", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
Betting.call(state, 1);
assert.equal(Betting.isStreetComplete(state), false, "the big blind still has an option");
Betting.check(state, 2);
assert.equal(Betting.isStreetComplete(state), true);
assert.equal(state.actingSeatIndex, null);
});
test("a raise reopens the action for everyone who had already called", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
assert.equal(state.seats[3].hasActed, true);
Betting.raiseTo(state, 1, 10);
assert.equal(state.seats[3].hasActed, false, "seat 3 owes a response again");
assert.equal(state.seats[0].hasActed, false);
assert.equal(state.currentBet, 10);
assert.equal(Betting.isStreetComplete(state), false);
});
test("the action skips a player who is all-in but still in the hand", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.seats[3].stack = 5;
Betting.beginHand(state);
Betting.allIn(state, 3);
assert.equal(state.seats[3].stack, 0);
assert.notEqual(state.actingSeatIndex, 3);
// They are still live and can still win the pot.
assert.ok(Betting.liveSeats(state).some(s => s.index === 3));
});
test("closing a street sweeps chips into hand totals and resets the bet", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
Betting.call(state, 1);
Betting.check(state, 2);
const before = chipsOnTable(state);
Betting.closeStreet(state);
assert.equal(state.currentBet, 0);
assert.equal(state.street, "flop");
assert.equal(chipsOnTable(state), before, "sweeping moves chips, it doesn't change the total");
state.seats.forEach(p => assert.equal(p.committed, 0));
assert.equal(Betting.handTotal(state.seats[2]), 2);
});
});
describe("Betting all-in run-outs", () => {
test("with everyone all-in there is nobody left to ask", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 20, dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.allIn(state, 2);
Betting.allIn(state, 0);
Betting.allIn(state, 1);
assert.equal(Betting.canStillBet(state), false);
assert.equal(Betting.isRunningOut(state), true, "the board just gets dealt out");
Betting.closeStreet(state);
assert.equal(state.actingSeatIndex, null, "no phantom action on the flop");
});
test("one player with chips against an all-in cannot bet alone", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.seats[1].stack = 5;
Betting.beginHand(state);
Betting.fold(state, 2);
Betting.allIn(state, 0);
Betting.allIn(state, 1);
assert.equal(Betting.canStillBet(state), false);
assert.equal(Betting.isHandOver(state), false, "two players are still live for the showdown");
});
test("everyone folding to one player ends the hand", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.fold(state, 3);
Betting.fold(state, 0);
Betting.fold(state, 1);
assert.equal(Betting.isHandOver(state), true);
});
});
describe("Betting money handling", () => {
test("a player can never put in more than they have", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 30, dealerSeatIndex: 0 });
Betting.beginHand(state);
const moved = Betting.putIn(state, 0, 500);
assert.equal(moved, 30);
assert.equal(state.seats[0].stack, 0);
});
test("raise-to is what a dealer says out loud, not raise-by", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.raiseTo(state, 3, 10);
assert.equal(state.seats[3].committed, 10, "their street total is 10, not 10 on top");
assert.equal(state.seats[3].stack, 90);
});
test("the minimum raise has to match the last one", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
assert.equal(Betting.minRaiseTo(state), 4, "a big blind of 2 makes the first raise 4");
Betting.raiseTo(state, 3, 10);
assert.equal(Betting.minRaiseTo(state), 18, "an 8-chip raise means the next one is to 18");
});
test("winnings land back in front of the player", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, amount: 42.5 }]);
assert.equal(state.seats[0].stack, 142.5, "the button posts nothing three-handed");
});
test("fractional blinds don't drift into floating-point dust", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 10, smallBlind: 0.1, bigBlind: 0.2 });
Betting.beginHand(state);
Betting.call(state, 0); // button limps
Betting.call(state, 1); // small blind completes
Betting.check(state, 2); // big blind is happy
Betting.closeStreet(state);
assert.equal(chipsOnTable(state), 30, "0.1 + 0.2 arithmetic didn't lose a chip");
assert.equal(Betting.potSoFar(state), 0.6);
state.seats.forEach(p => assert.equal(p.stack, 9.8));
});
});
+213
View File
@@ -0,0 +1,213 @@
// Randomised whole-hand play against the invariants a dealer would notice being broken.
//
// These ran once, by hand, in a browser console while the betting engine was being
// fixed, and then went away with the tab. The bugs they caught (blinds posted to empty
// chairs, the button skipping players, heads-up acting out of order) are all cases you
// only hit on a particular table shape, so a fixed set of examples misses them. Keeping
// the generator means the next change gets checked against the same thousand tables.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, PotCalculator, HoldemState, Money, makeTable, chipsOnTable, contributions } = require("./load");
// Deterministic PRNG: a failure has to be reproducible from the seed printed with it,
// otherwise a randomised suite is just a flaky one.
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
// Plays one hand to completion with random but legal-ish dealer input.
function playHand(random) {
const seatCount = 2 + Math.floor(random() * 9);
const seatIndexes = [...Array(seatCount).keys()];
const seated = seatIndexes.filter(() => random() < 0.7);
if (seated.length < 2) seated.push(...seatIndexes.filter(i => !seated.includes(i)).slice(0, 2 - seated.length));
const bigBlind = [2, 5, 0.5][Math.floor(random() * 3)];
const state = makeTable({
seatCount,
seated,
stack: 0,
smallBlind: bigBlind / 2,
bigBlind,
dealerSeatIndex: Math.floor(random() * seatCount),
});
// Varied stack depths, so short stacks and side pots actually happen.
seated.forEach(i => {
state.seats[i].stack = Money.round(bigBlind * (1 + Math.floor(random() * 60)));
});
const startingChips = chipsOnTable(state);
Betting.beginHand(state);
let guard = 0;
while (!Betting.isHandOver(state) && state.street !== "done" && guard++ < 400) {
if (state.actingSeatIndex === null) {
if (state.street === "river" || !Betting.canStillBet(state)) break;
Betting.closeStreet(state);
continue;
}
const index = state.actingSeatIndex;
const player = state.seats[index];
const toCall = Betting.toCall(state, player);
const roll = random();
if (roll < 0.15) Betting.fold(state, index);
else if (roll < 0.25) Betting.allIn(state, index);
else if (toCall > 0) Betting.call(state, index);
else if (roll < 0.75) Betting.check(state, index);
else Betting.raiseTo(state, index, Betting.minRaiseTo(state));
}
Betting.sweepToHandTotals(state);
return { state, startingChips, seated };
}
describe("randomised hands hold the dealer's invariants", () => {
const HANDS = 1000;
test(`${HANDS} hands never create or destroy a chip`, () => {
const random = rng(20260901);
for (let hand = 0; hand < HANDS; hand++) {
const { state, startingChips } = playHand(random);
assert.equal(
chipsOnTable(state),
startingChips,
`hand ${hand}: chips on the table changed during play`
);
}
});
test(`${HANDS} hands: pots plus uncalled returns always equal the money in`, () => {
const random = rng(778899);
let sidePotHands = 0;
let returnHands = 0;
for (let hand = 0; hand < HANDS; hand++) {
const { state } = playHand(random);
const players = contributions(state);
const inThePot = players.reduce((sum, p) => sum + p.contribution, 0);
if (inThePot <= 0) continue;
// Fold flags can leave nobody live, which the calculator deliberately
// refuses to guess at — that's a dealer correction, not an invariant.
if (players.every(p => p.folded)) continue;
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
const returned = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
assert.equal(
Money.toCents(layered + returned),
Money.toCents(inThePot),
`hand ${hand}: pot breakdown doesn't add up to what went in`
);
if (PotCalculator.layers(players).length > 1) sidePotHands++;
if (returned > 0) returnHands++;
}
assert.ok(sidePotHands > 20, `expected side pots to occur; saw ${sidePotHands}`);
assert.ok(returnHands > 20, `expected uncalled bets to occur; saw ${returnHands}`);
});
test(`${HANDS} hands: what's left after returns is money somebody actually matched`, () => {
// The rule the pot breakdown rests on: you are only called up to what somebody
// else was willing to put in. Once the uncalled chips are handed back, no
// player should still be sitting above every other player at the table.
//
// A single-eligible pot is *not* the thing to look for here — that's legitimate
// when a player called and then folded, leaving dead money one contender
// collects. The bug this guards against is subtler: chips nobody ever matched
// being dressed up as a pot to play for.
const random = rng(13579);
for (let hand = 0; hand < HANDS; hand++) {
const { state } = playHand(random);
const players = contributions(state);
if (players.every(p => p.folded)) continue;
const returns = PotCalculator.uncalledReturns(players);
assert.ok(returns.length <= 1, `hand ${hand}: only the biggest bet can go uncalled`);
returns.forEach(r => {
const player = players.find(p => p.id === r.id);
assert.ok(r.amount <= player.contribution + 0.001, `hand ${hand}: returned more than was put in`);
});
const returned = new Map(returns.map(r => [r.id, r.amount]));
const capped = players.map(p => Money.toCents(p.contribution - (returned.get(p.id) || 0)));
capped.forEach((cents, index) => {
const others = capped.filter((_, i) => i !== index);
const mostAnyoneElsePaid = others.length ? Math.max(...others) : 0;
assert.ok(
cents <= mostAnyoneElsePaid,
`hand ${hand}: a player is still ${Money.fromCents(cents - mostAnyoneElsePaid)} ` +
`above everyone else after returns — that money was never matched`
);
});
}
});
test(`${HANDS} hands: paying the pot out settles it exactly`, () => {
const random = rng(24680);
for (let hand = 0; hand < HANDS; hand++) {
const { state } = playHand(random);
const players = contributions(state);
if (players.every(p => p.folded)) continue;
// Take the uncalled money off first, the way the dealer would.
const returned = new Map(PotCalculator.uncalledReturns(players).map(r => [r.id, r.amount]));
const contested = players.map(p => ({
...p,
contribution: Money.round(p.contribution - (returned.get(p.id) || 0)),
}));
let entries = PotCalculator.buildEntries(contested);
const potBefore = PotCalculator.totalPot(entries);
let paidOut = 0;
let rounds = 0;
while (PotCalculator.getContenders(entries).length > 0 && rounds++ < 30) {
const contenders = PotCalculator.getContenders(entries);
const winner = contenders[Math.floor(random() * contenders.length)];
const result = PotCalculator.resolveRound(entries, [winner.id]);
paidOut += result.perWinner.reduce((s, w) => s + w.amount, 0);
entries = result.updatedEntries;
}
assert.equal(
Money.toCents(paidOut),
Money.toCents(potBefore),
`hand ${hand}: paying out the pot left ${potBefore - paidOut} unaccounted for`
);
}
});
test(`${HANDS} hands: the button and blinds land on real players`, () => {
const random = rng(112233);
for (let hand = 0; hand < HANDS; hand++) {
const seatCount = 2 + Math.floor(random() * 9);
const seated = [...Array(seatCount).keys()].filter(() => random() < 0.6);
if (seated.length < 2) continue;
const state = makeTable({ seatCount, seated, dealerSeatIndex: Math.floor(random() * seatCount) });
HoldemState.advanceButton(state);
assert.ok(state.seats[state.dealerSeatIndex].inHand, `hand ${hand}: button on an empty chair`);
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.ok(state.seats[sbIndex].inHand, `hand ${hand}: small blind on an empty chair`);
assert.ok(state.seats[bbIndex].inHand, `hand ${hand}: big blind on an empty chair`);
assert.notEqual(sbIndex, bbIndex, `hand ${hand}: both blinds on one player`);
if (seated.length === 2) {
assert.ok(headsUp, `hand ${hand}: two players should be heads-up`);
assert.equal(sbIndex, state.dealerSeatIndex, `hand ${hand}: heads-up button posts the small blind`);
}
}
});
});
+80
View File
@@ -0,0 +1,80 @@
// Loads the dealer's browser modules into Node so they can be tested directly.
//
// The modules are plain script-tag files: each one declares a `const` namespace and
// ends with a `module.exports` guard. In the browser they find each other as globals,
// so the same names have to exist on `globalThis` here or `Betting` can't reach
// `HoldemState`. Load order matches the <script> order in TexasHoldem.cshtml.
const path = require("path");
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
function load(name) {
const mod = require(path.join(SRC, `${name}.js`));
globalThis[name.charAt(0).toUpperCase() + name.slice(1)] = mod;
return mod;
}
const Money = load("money");
const PotCalculator = load("potCalculator");
const SplitCalculator = load("splitCalculator");
const Betting = load("betting");
const HandHistoryExport = load("handHistoryExport");
// state.js exports `HoldemState`, which doesn't match its filename.
const HoldemState = require(path.join(SRC, "state.js"));
globalThis.HoldemState = HoldemState;
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
// in with `stack` chips each. Everything else is the app's own defaults, so a test
// that forgets to set a field gets whatever a real dealer would get.
function makeTable({ seatCount = 6, seated = null, stack = 100, smallBlind = 1, bigBlind = 2, dealerSeatIndex = 0 } = {}) {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, seatCount);
state.smallBlind = smallBlind;
state.bigBlind = bigBlind;
state.dealerSeatIndex = dealerSeatIndex;
const sitting = seated || state.seats.map((_, i) => i);
state.seats.forEach((player, index) => {
player.inHand = sitting.includes(index);
player.stack = sitting.includes(index) ? stack : null;
player.name = `P${index + 1}`;
});
return state;
}
// Total chips the table is accountable for: what's in front of everyone, plus what
// they've pushed in this street, plus what's already banked into the pot.
function chipsOnTable(state) {
return Money.round(
state.seats.reduce(
(sum, p) => sum + (Number(p.stack) || 0) + (Number(p.committed) || 0) + (Number(p.amountOverride) || 0),
0
)
);
}
// Contributions in the shape PotCalculator wants them.
function contributions(state) {
return state.seats
.filter(p => p.inHand)
.map(p => ({
id: p.id,
contribution: Betting.handTotal(p),
folded: p.folded,
}));
}
module.exports = {
Money,
HandHistoryExport,
PotCalculator,
SplitCalculator,
Betting,
HoldemState,
makeTable,
chipsOnTable,
contributions,
};
+260
View File
@@ -0,0 +1,260 @@
// Home games are messy. People walk up mid-hand, storm off mid-hand, argue about
// whose blind it is, and pass the button round three times before anyone deals.
// These are the cases the positional rules quietly get wrong.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, PotCalculator, Money, makeTable, chipsOnTable, contributions } = require("./load");
const potOf = state => HoldemState.totalPot(state);
describe("a player leaves in the middle of a hand", () => {
test("their chips stay in the pot", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
const before = potOf(state);
assert.equal(before, 8);
state.seats[1].inHand = false; // dealer takes them out
assert.equal(potOf(state), before, "the pot must not shrink when a player is taken out");
});
test("they cannot be paid out of a pot they walked away from", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
state.seats[1].inHand = false;
const players = state.seats
.filter(p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0)
.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
folded: p.folded || HoldemState.isDeadMoney(p),
}));
const layers = PotCalculator.layers(players);
assert.ok(!layers[0].eligible.includes(state.seats[1].id), "a departed player can't win");
assert.equal(layers.reduce((s, l) => s + l.amount, 0), 6, "but their money is still being played for");
});
test("taking out the player who is to act moves the action on", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
const acting = state.actingSeatIndex;
state.seats[acting].inHand = false;
Betting.passAction(state, acting);
assert.notEqual(state.actingSeatIndex, acting, "the table can't wait on somebody who left");
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
});
test("an empty chair still contributes nothing, base pot or not", () => {
// The table-wide default must not be read as an ante from seats nobody is in.
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0 });
state.basePotAmount = 25;
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, 25), 0);
});
});
describe("a player joins in the middle of a hand", () => {
test("they sit the hand out rather than joining a pot they never paid into", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
// Walks up and is dealt in — the app marks them folded for this hand.
state.seats[4].inHand = true;
state.seats[4].stack = 100;
state.seats[4].folded = true;
assert.ok(!Betting.liveSeats(state).some(s => s.index === 4), "not live this hand");
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, state.basePotAmount), 0);
});
test("they are a full player again on the next hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
state.seats[4].inHand = true;
state.seats[4].stack = 100;
state.seats[4].folded = true;
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.seats[4].folded, false);
assert.ok(Betting.liveSeats(state).some(s => s.index === 4), "dealt in like anyone else");
});
});
describe("passing the button around before the cards come out", () => {
test("moving the button re-posts the blinds and keeps the chip count straight", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
const startingChips = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(state.seats[1].committed, 1);
assert.equal(state.seats[2].committed, 2);
state.dealerSeatIndex = 2;
Betting.repostBlinds(state);
assert.equal(state.seats[1].committed, 0, "the old small blind got their chip back");
assert.equal(state.seats[3].committed, 1, "and the new one has posted");
assert.equal(state.seats[0].committed, 2);
assert.equal(chipsOnTable(state), startingChips, "no chips created or lost in the shuffle");
});
test("passing it round many times still balances", () => {
const state = makeTable({ seatCount: 8, seated: [0, 2, 3, 5, 7], dealerSeatIndex: 0 });
const startingChips = chipsOnTable(state);
Betting.beginHand(state);
[2, 5, 7, 3, 0, 5].forEach(seat => {
state.dealerSeatIndex = seat;
Betting.repostBlinds(state);
assert.equal(chipsOnTable(state), startingChips, `chips drifted after passing to seat ${seat}`);
});
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.equal(Betting.committed(state.seats[sbIndex]), 1);
assert.equal(Betting.committed(state.seats[bbIndex]), 2);
});
test("once somebody has acted, the blinds are settled", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
assert.equal(Betting.onlyBlindsIn(state), true, "nothing but blinds so far");
Betting.call(state, 3);
assert.equal(Betting.onlyBlindsIn(state), false, "a call means the blinds are set where they are");
});
});
describe("naming the blinds by hand", () => {
test("a named blind wins over the button order", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
assert.equal(sbIndex, 3);
assert.equal(bbIndex, 0);
assert.equal(manual, true);
Betting.beginHand(state);
assert.equal(state.seats[3].committed, 1);
assert.equal(state.seats[0].committed, 2);
assert.equal(state.seats[1].committed, 0, "the positional small blind posts nothing");
});
test("a named blind on a player who isn't dealt in is ignored", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.blindOverride = { sbIndex: 4, bbIndex: 0 }; // seat 4 is an empty chair
const { sbIndex, manual } = HoldemState.getBlindSeats(state);
assert.ok(!manual, "falls back to the button order rather than posting to nobody");
assert.ok(state.seats[sbIndex].inHand);
});
test("both blinds on one player is refused", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.blindOverride = { sbIndex: 1, bbIndex: 1 };
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.notEqual(sbIndex, bbIndex);
});
test("naming blinds heads-up still acts in the right order", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
state.blindOverride = { sbIndex: 5, bbIndex: 2 }; // deliberately the reverse
Betting.beginHand(state);
assert.equal(state.seats[5].committed, 1);
assert.equal(state.seats[2].committed, 2);
assert.equal(state.actingSeatIndex, 5, "the small blind acts first pre-flop heads-up");
Betting.call(state, 5);
Betting.check(state, 2);
Betting.closeStreet(state);
assert.equal(state.actingSeatIndex, 2, "and the big blind leads after the flop");
});
test("the naming only lasts the hand it was made for", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.equal(state.blindOverride, null, "blinds go back to following the button");
});
});
describe("the first hand on a fresh table", () => {
test("the button never starts on an empty chair", () => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
[4, 6, 7].forEach(i => { state.seats[i].inHand = true; state.seats[i].stack = 100; });
assert.equal(state.dealerSeatIndex, 0, "a fresh table starts the button on seat 1");
assert.equal(state.seats[0].inHand, false, "which nobody is sitting in");
Betting.beginHand(state);
assert.ok(state.seats[state.dealerSeatIndex].inHand, "the button moved to a real player");
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.ok(state.seats[sbIndex].inHand);
assert.ok(state.seats[bbIndex].inHand);
assert.notEqual(sbIndex, bbIndex);
});
test("a table that empties out around the button recovers", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 3 });
state.seats[3].inHand = false; // the player holding the button leaves
HoldemState.ensureButtonOnPlayer(state);
assert.ok(state.seats[state.dealerSeatIndex].inHand);
});
test("two players left after everyone else walks is heads-up, blinds reversed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
[2, 3].forEach(i => { state.seats[i].inHand = false; });
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");
});
});
describe("chips survive a messy table", () => {
test("joining, leaving and passing the button never changes the chip count", () => {
const state = makeTable({ seatCount: 8, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[6].stack = 100; // sitting down but not dealt in yet
const startingChips = chipsOnTable(state);
Betting.beginHand(state);
state.dealerSeatIndex = 2;
Betting.repostBlinds(state);
state.seats[6].inHand = true; // walks up mid-hand
state.seats[6].folded = true;
assert.equal(chipsOnTable(state), startingChips);
Betting.call(state, state.actingSeatIndex);
state.seats[1].inHand = false; // and someone storms off
Betting.sweepToHandTotals(state);
assert.equal(chipsOnTable(state), startingChips, "every chip is still accounted for");
assert.equal(
Money.toCents(potOf(state) + state.seats.reduce((s, p) => s + (Number(p.stack) || 0), 0)),
Money.toCents(startingChips),
"and the pot plus the stacks is the whole table"
);
});
});
+126
View File
@@ -0,0 +1,126 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Money, HandHistoryExport } = require("./load");
describe("Money.format", () => {
test("whole amounts drop the pointless cents", () => {
assert.equal(Money.format(12), "$12");
assert.equal(Money.format(450), "$450");
assert.equal(Money.format(1250000), "$1,250,000");
assert.equal(Money.format(0), "$0");
});
test("amounts that carry cents still show them", () => {
assert.equal(Money.format(12.5), "$12.50");
assert.equal(Money.format(0.25), "$0.25");
assert.equal(Money.format(3.01), "$3.01");
});
test("a quarter-blind game reads exactly as it did", () => {
// The case that would be ruined by dropping cents unconditionally.
[0.25, 0.5, 1.75, 12.05].forEach(v => {
assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`);
});
});
test("floating-point dust doesn't invent cents", () => {
assert.equal(Money.format(0.1 + 0.2), "$0.30");
assert.equal(Money.format(1.0000000001), "$1");
});
test("signed amounts put the sign out front, both scales", () => {
assert.equal(Money.formatSigned(87500), "+$87,500");
assert.equal(Money.formatSigned(-87500), "-$87,500");
assert.equal(Money.formatSigned(-12.5), "-$12.50");
assert.equal(Money.formatSigned(0), "$0");
});
test("rubbish in doesn't produce rubbish out", () => {
assert.equal(Money.format(null), "$0");
assert.equal(Money.format(undefined), "$0");
assert.equal(Money.format("nonsense"), "$0");
});
});
describe("Money.forSheet", () => {
test("a bare number a spreadsheet will add up", () => {
assert.equal(Money.forSheet(1250000), "1250000.00");
assert.equal(Money.forSheet(12.5), "12.50");
assert.equal(Money.forSheet(-87500), "-87500.00");
});
test("no currency symbol and no thousands separators", () => {
const written = Money.forSheet(1234567.89);
assert.ok(!written.includes("$"), "a $ makes the cell text");
assert.ok(!written.includes(","), "a comma makes the cell text");
assert.equal(Number(written), 1234567.89, "and it parses straight back to a number");
});
});
describe("the exported CSV", () => {
const history = [
{ handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] },
{ handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] },
];
const standings = [
{ id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 },
{ id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 },
];
// Splitting on commas isn't good enough: the timestamp column legitimately holds
// one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason.
const splitRow = line => {
const fields = [];
let field = "";
let quoted = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (quoted) {
if (c === '"' && line[i + 1] === '"') { field += '"'; i++; }
else if (c === '"') quoted = false;
else field += c;
} else if (c === '"') quoted = true;
else if (c === ",") { fields.push(field); field = ""; }
else field += c;
}
fields.push(field);
return fields;
};
test("every money cell parses as a number", () => {
const csv = HandHistoryExport.toCsv(history, standings);
const lines = csv.split("\r\n");
lines.slice(1, 3).forEach(line => {
const cell = splitRow(line)[2]; // Pot
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.
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
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`);
});
});
});
test("a million-dollar pot doesn't need quoting any more", () => {
const csv = HandHistoryExport.toCsv(history, []);
assert.ok(csv.includes("1250000.00"), "written plainly");
assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string");
});
test("names and payout descriptions stay human", () => {
const csv = HandHistoryExport.toCsv(history, standings);
assert.ok(csv.includes("Alexandra"), "the name column is still a name");
assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence");
});
test("a name with a comma is still quoted properly", () => {
const csv = HandHistoryExport.toCsv([], [
{ id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 },
]);
assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed");
});
});
+124
View File
@@ -0,0 +1,124 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { PotCalculator } = require("./load");
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
describe("PotCalculator.layers", () => {
test("one pot when everyone put in the same", () => {
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("c", 50)]);
assert.equal(layers.length, 1);
assert.equal(layers[0].amount, 150);
assert.equal(layers[0].label, "Main pot");
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
});
test("a short all-in opens a side pot only the deeper players can win", () => {
// a is all-in for 20, b and c keep betting to 60.
const layers = PotCalculator.layers([p("a", 20), p("b", 60), p("c", 60)]);
assert.equal(layers.length, 2);
assert.equal(layers[0].amount, 60); // 20 x 3
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
assert.equal(layers[1].amount, 80); // 40 x 2
assert.deepEqual(layers[1].eligible.sort(), ["b", "c"]);
assert.equal(layers[1].label, "Side pot 1");
});
test("folded money stays in the pot as dead money but wins nothing", () => {
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("folder", 50, true)]);
assert.equal(layers.length, 1);
assert.equal(layers[0].amount, 150, "the folder's chips are still in the middle");
assert.ok(!layers[0].eligible.includes("folder"));
});
test("layers with the same eligible players are announced as one pot", () => {
// Two folders at different heights would each open a band, but a dealer
// announces one pot between the two players who can actually win it.
const layers = PotCalculator.layers([
p("a", 100),
p("b", 100),
p("f1", 20, true),
p("f2", 40, true),
]);
const eligibleSets = layers.map(l => l.eligible.slice().sort().join(","));
assert.deepEqual(eligibleSets, ["a,b"], "one announceable pot, not three");
assert.equal(layers[0].amount, 260);
});
test("layer amounts always sum to the contested money", () => {
const players = [p("a", 12.5), p("b", 33.25), p("c", 33.25), p("d", 5, true)];
const total = players.reduce((s, x) => s + x.contribution, 0);
const uncalled = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
assert.equal(Math.round((layered + uncalled) * 100), Math.round(total * 100));
});
test("nobody contributed means no pots, not an empty main pot", () => {
assert.deepEqual(PotCalculator.layers([p("a", 0), p("b", 0)]), []);
});
});
describe("PotCalculator.uncalledReturns", () => {
test("an overbet nobody called goes back to whoever bet it", () => {
// b shoves 200, a calls only 50 and everyone else folds.
const returns = PotCalculator.uncalledReturns([p("a", 50), p("b", 200)]);
assert.deepEqual(returns, [{ id: "b", amount: 150 }]);
});
test("a called bet leaves nothing to return", () => {
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 50), p("b", 50)]), []);
});
test("the cap is the biggest live contribution, so a folder's overbet comes back", () => {
const returns = PotCalculator.uncalledReturns([p("live", 30), p("bully", 90, true)]);
assert.deepEqual(returns, [{ id: "bully", amount: 60 }]);
});
test("with nobody live it stays quiet rather than refunding the table", () => {
// Every fold flag set is a dealer mistake to correct, not a signal that
// every chip on the felt was uncalled.
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 10, true), p("b", 20, true)]), []);
});
});
describe("PotCalculator.resolveRound", () => {
test("a single winner takes the layer they were matched for", () => {
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
const result = PotCalculator.resolveRound(entries, ["a"]);
assert.equal(result.payoutPool, 100);
assert.deepEqual(result.perWinner, [{ id: "a", amount: 100 }]);
assert.equal(result.remainingContenders.length, 0);
});
test("an all-in winner only takes what everyone matched, the rest rolls forward", () => {
const entries = PotCalculator.buildEntries([p("short", 20), p("b", 60), p("c", 60)]);
const result = PotCalculator.resolveRound(entries, ["short"]);
assert.equal(result.capApplied, 20);
assert.equal(result.payoutPool, 60, "20 from each of three players");
// b and c still have 40 each left to play for.
assert.deepEqual(result.remainingContenders.map(e => e.id).sort(), ["b", "c"]);
assert.equal(PotCalculator.totalPot(result.updatedEntries), 80);
});
test("a chopped pot splits evenly", () => {
const entries = PotCalculator.buildEntries([p("a", 30), p("b", 30)]);
const result = PotCalculator.resolveRound(entries, ["a", "b"]);
assert.deepEqual(result.perWinner, [{ id: "a", amount: 30 }, { id: "b", amount: 30 }]);
});
test("odd cents go to the winners in selection order and nothing is lost", () => {
// A $10.01 pot three ways doesn't divide: 3.34 / 3.34 / 3.33.
const entries = PotCalculator.buildEntries([p("a", 3.34), p("b", 3.34), p("c", 3.33)]);
const result = PotCalculator.resolveRound(entries, ["a", "b", "c"]);
const paid = result.perWinner.reduce((s, w) => s + w.amount, 0);
assert.equal(Math.round(paid * 100), Math.round(result.payoutPool * 100), "no chips lost to rounding");
assert.ok(result.perWinner[0].amount >= result.perWinner[2].amount, "odd cent goes to the first selected");
});
test("selecting nobody who can win is an error, not a silent no-op", () => {
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
});
});
+144
View File
@@ -0,0 +1,144 @@
// Values a dealer can actually produce with a typo, and the ones a corrupted save
// can produce on its own. None of these should be able to move chips.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
describe("a stack can never go negative", () => {
test("a mistyped negative stack reads as empty, not as owing chips", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats[0].stack = -50;
assert.equal(Betting.stackOf(state, state.seats[0]), 0);
});
test("a negative stack can't be bet with", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats[0].stack = -50;
const before = state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0);
const moved = Betting.putIn(state, 0, 25);
assert.equal(moved, 0, "nothing moves out of an empty stack");
assert.ok(Betting.stackOf(state, state.seats[0]) >= 0);
assert.equal(state.seats[0].committed, 0, "and nothing appears in front of them");
assert.ok(state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0) <= before);
});
test("a negative table default doesn't hand everyone a negative stack", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats.forEach(p => { p.stack = null; });
state.defaultStack = -100;
state.seats.forEach(p => assert.equal(Betting.stackOf(state, p), 0));
});
test("a negative stack doesn't make a player look like they can still bet", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats[0].stack = -50;
assert.ok(!Betting.seatsWithChips(state).some(s => s.index === 0));
});
});
describe("blinds that can't be posted", () => {
test("negative blinds post nothing rather than paying players to sit down", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.smallBlind = -5;
state.bigBlind = -10;
const before = chipsOnTable(state);
Betting.beginHand(state);
state.seats.forEach(p => assert.equal(p.committed, 0));
assert.equal(chipsOnTable(state), before);
});
test("an unreadable blind doesn't wedge the hand", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.bigBlind = NaN;
state.smallBlind = 1;
Betting.beginHand(state);
assert.ok(Number.isFinite(state.currentBet), "the bet to match is still a number");
assert.ok(!Number.isNaN(Betting.potSoFar(state)));
});
test("blinds nobody can cover put those players all-in and move on", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 10 });
state.smallBlind = 500;
state.bigBlind = 1000;
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before, "no chips invented covering a blind");
state.seats.forEach(p => assert.ok((Number(p.stack) || 0) >= 0));
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
});
});
describe("a saved table that comes back damaged", () => {
test("unreadable storage falls back to a fresh table", () => {
// load() has to survive whatever is in localStorage, because the alternative
// is a dealer opening the tool mid-session to a blank screen.
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => "{not json at all",
setItem: () => {},
removeItem: () => {},
};
const state = HoldemState.load();
globalThis.localStorage = original;
assert.equal(state.seats.length, HoldemState.DEFAULT_SEAT_COUNT);
assert.equal(state.handNumber, 1);
});
test("a save missing its seats is treated as no save at all", () => {
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => JSON.stringify({ handNumber: 7, smallBlind: 3 }),
setItem: () => {},
removeItem: () => {},
};
const state = HoldemState.load();
globalThis.localStorage = original;
assert.ok(Array.isArray(state.seats) && state.seats.length > 0);
});
test("a save from before a field existed gets the field, not undefined", () => {
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => JSON.stringify({
seats: [{ id: "a", name: "Ann", inHand: true }, { id: "b", name: "Ben", inHand: true }],
handNumber: 4,
}),
setItem: () => {},
removeItem: () => {},
};
const state = HoldemState.load();
globalThis.localStorage = original;
assert.equal(state.blindOverride, null);
assert.equal(state.street, "preflop");
state.seats.forEach(p => {
assert.equal(p.committed, 0);
assert.equal(p.hasActed, false);
assert.equal(p.buyIns, 0);
});
// And it has to be playable, not just parseable.
assert.doesNotThrow(() => Betting.beginHand(state));
});
test("storage that refuses to be written doesn't take the table down", () => {
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => null,
setItem: () => { throw new Error("QuotaExceededError"); },
removeItem: () => {},
};
const state = HoldemState.createDefault();
assert.doesNotThrow(() => HoldemState.save(state), "a full disk must not break the hand in progress");
globalThis.localStorage = original;
});
});
+65
View File
@@ -0,0 +1,65 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { SplitCalculator, Money } = require("./load");
describe("SplitCalculator.split", () => {
test("an even chop hands everyone the same stack", () => {
const result = SplitCalculator.split(90, 3, 1);
assert.equal(result.each, 30);
assert.equal(result.oddChips, 0);
assert.equal(result.dust, 0);
assert.equal(SplitCalculator.accountedFor(result), 90);
});
test("what won't divide is called out as odd chips, not rolled into a stack", () => {
// $100 three ways in $5 chips: 20 chips, 6 each, 2 left over.
const result = SplitCalculator.split(100, 3, 5);
assert.equal(result.each, 30);
assert.equal(result.oddChips, 2);
assert.equal(SplitCalculator.accountedFor(result), 100);
});
test("a pot that isn't a whole number of chips surfaces the remainder", () => {
// $47 in $5 chips: 9 chips ($45) and $2 that no chip in play can pay.
const result = SplitCalculator.split(47, 2, 5);
assert.equal(result.dust, 2, "the dealer picked a denomination the pot can't make");
assert.equal(SplitCalculator.accountedFor(result), 47);
});
test("every split accounts for the whole pot", () => {
const chips = [0.25, 0.5, 1, 5, 25, 100];
for (let pot = 1; pot <= 400; pot += 7) {
for (let ways = 2; ways <= 6; ways++) {
chips.forEach(chip => {
const result = SplitCalculator.split(pot, ways, chip);
assert.equal(
Money.toCents(SplitCalculator.accountedFor(result)),
Money.toCents(pot),
`${pot} split ${ways} ways in ${chip} chips lost money`
);
});
}
}
});
test("odd chips are always fewer than the number of players", () => {
for (let pot = 1; pot <= 200; pot += 3) {
for (let ways = 2; ways <= 8; ways++) {
const result = SplitCalculator.split(pot, ways, 1);
assert.ok(result.oddChips < ways, "a whole extra chip each would have been dealt out");
}
}
});
test("an empty pot isn't a valid split", () => {
assert.equal(SplitCalculator.split(0, 3, 1).valid, false);
assert.equal(SplitCalculator.split(-5, 3, 1).valid, false);
});
test("nonsense inputs fall back to something a dealer can read", () => {
const result = SplitCalculator.split(50, 0, 0);
assert.equal(result.players, 1, "a split has to be at least one way");
assert.equal(result.chip, 0.01, "and at least one cent a chip");
assert.equal(SplitCalculator.accountedFor(result), 50);
});
});
+147
View File
@@ -0,0 +1,147 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { HoldemState, makeTable } = require("./load");
describe("HoldemState.advanceButton", () => {
test("the button moves to the next player, not the next empty chair", () => {
// Four players spread around a ten-seat table: 0, 3, 4, 8.
const state = makeTable({ seatCount: 10, seated: [0, 3, 4, 8], dealerSeatIndex: 0 });
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 3, "skips the three empty chairs between");
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 4);
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 8);
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 0, "wraps back round to the first player");
});
test("every player gets the button once per orbit", () => {
const seated = [1, 2, 6, 7, 9];
const state = makeTable({ seatCount: 12, seated, dealerSeatIndex: 1 });
const visited = [];
for (let i = 0; i < seated.length; i++) {
visited.push(state.dealerSeatIndex);
HoldemState.advanceButton(state);
}
assert.deepEqual(visited.slice().sort((a, b) => a - b), seated);
});
test("it still moves when nobody is dealt in, rather than looking stuck", () => {
const state = makeTable({ seatCount: 6, seated: [], dealerSeatIndex: 2 });
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 3);
});
test("counterclockwise play runs the button the other way", () => {
const state = makeTable({ seatCount: 6, seated: [0, 2, 4], dealerSeatIndex: 4 });
state.direction = "counterclockwise";
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 2);
});
});
describe("HoldemState.getBlindSeats", () => {
test("blinds follow the players, not the chairs left of the button", () => {
// Seats 1 and 2 are empty, so the blinds belong on 3 and 7.
const state = makeTable({ seatCount: 9, seated: [0, 3, 7], dealerSeatIndex: 0 });
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, false);
assert.equal(sbIndex, 3);
assert.equal(bbIndex, 7);
});
test("heads-up, the button posts the small blind", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, true);
assert.equal(sbIndex, 2, "the button is the small blind heads-up");
assert.equal(bbIndex, 5);
});
test("heads-up with the button on an empty chair still posts both blinds", () => {
const state = makeTable({ seatCount: 6, seated: [1, 4], dealerSeatIndex: 0 });
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, true);
assert.equal(sbIndex, 1);
assert.equal(bbIndex, 4);
assert.notEqual(sbIndex, bbIndex, "two blinds never land on one player");
});
test("fewer than two players means no blinds to post", () => {
const state = makeTable({ seatCount: 6, seated: [3], dealerSeatIndex: 0 });
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.equal(sbIndex, null);
assert.equal(bbIndex, null);
});
});
describe("HoldemState.sessionStandings", () => {
test("a player's cash position counts chips in front plus chips in the pot", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 0 });
const [a] = state.seats;
a.buyIns = 100;
a.stack = 60;
a.amountOverride = 25; // banked into the hand in progress
a.committed = 15; // still in front of them on this street
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 0 }]);
const standings = HoldemState.sessionStandings(state);
const row = standings.find(s => s.id === a.id);
// 60 in front + 40 in the pot - 100 bought in = level.
assert.equal(row.cashPosition, 0, "money in the pot is still theirs until it's won");
assert.equal(row.boughtIn, 100);
assert.equal(row.chipsNow, 60);
});
test("hand results accumulate across the session", () => {
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
const [a, b] = state.seats;
a.amountOverride = 10;
b.amountOverride = 10;
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 20 }]);
a.amountOverride = 5;
b.amountOverride = 5;
HoldemState.recordHand(state, [{ id: b.id, label: "P2", amount: 10 }]);
const standings = HoldemState.sessionStandings(state);
assert.equal(standings.find(s => s.id === a.id).net, 5);
assert.equal(standings.find(s => s.id === b.id).net, -5);
assert.equal(state.handNumber, 3, "the counter moved on twice");
});
test("the history is capped so a long session can still be saved", () => {
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
for (let i = 0; i < HoldemState.MAX_HISTORY_ENTRIES + 25; i++) {
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 1 }]);
}
assert.equal(state.handHistory.length, HoldemState.MAX_HISTORY_ENTRIES);
assert.equal(state.handHistory[0].handNumber, HoldemState.MAX_HISTORY_ENTRIES + 25, "newest first");
});
});
describe("HoldemState.resizeSeats", () => {
test("the table is clamped to a size a dealer could actually run", () => {
const state = makeTable({ seatCount: 6 });
HoldemState.resizeSeats(state, 100);
assert.equal(state.seatCount, 24);
HoldemState.resizeSeats(state, 1);
assert.equal(state.seatCount, 2);
});
test("an unreadable seat count falls back to a standard table", () => {
// Empty or non-numeric input (and zero, which is equally meaningless)
// lands on the default rather than a two-seat table.
const state = makeTable({ seatCount: 6 });
HoldemState.resizeSeats(state, NaN);
assert.equal(state.seatCount, HoldemState.DEFAULT_SEAT_COUNT);
});
test("shrinking past the button brings it back onto the table", () => {
const state = makeTable({ seatCount: 9, dealerSeatIndex: 8 });
HoldemState.resizeSeats(state, 4);
assert.ok(state.dealerSeatIndex < 4);
});
});