Compare commits

..
Author SHA1 Message Date
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
jheaps e8d6098d39 Merge pull request 'Add chips to a seat without leaving the table' (#32) from feature/casino-holdem-rebuys into master
Build and Deploy / build-deploy (push) Successful in 1m7s
Reviewed-on: #32
2026-08-31 23:57:01 -06:00
jheaps 5d1a6b7399 Merge branch 'master' into feature/casino-holdem-rebuys 2026-08-31 23:56:53 -06:00
jheaps a816b32426 Merge pull request 'A rulings reference for the calls that come up mid-hand' (#30) from feature/casino-holdem-rulings into master
Build and Deploy / build-deploy (push) Canceled after 31s
Reviewed-on: #30
2026-08-31 23:56:40 -06:00
jheaps 5271d5fbad Merge branch 'master' into feature/casino-holdem-rulings 2026-08-31 23:56:32 -06:00
jheapsandClaude Sonnet 5 552ec1a542 Add chips to a seat without leaving the table
Somebody rebuying is the most common interruption in a cash game, and the only
way to record it was to open the seat editor and retype a stack from memory.

Press and hold a seat and there's now "Add chips…": quick amounts off the
table's starting stack — half a buy-in, a buy-in, two — with a keypad for the
times it isn't one of those. It's undoable like every other seat action, and it
records cash handed over as well as chips added, because those answer different
questions. The stack moves every hand; what a player has paid in only moves
when they buy.

The export gains bought in, chips now, and up/down, so settling up at the end of
the night is reading a row rather than reconstructing it.

Getting that last column right needed care. Chips in front of a player disagree
with the hand log for as long as a hand is open — and since blinds post the
instant the previous hand settles, that window is almost always. Counting what a
player already has in the current pot as still theirs makes the cash position
agree with the hand log at any moment, including across a rebuy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:53:15 -06:00
jheaps 5c807d1581 Merge pull request 'Feature/casino holdem sidebar polish' (#31) from feature/casino-holdem-sidebar-polish into master
Build and Deploy / build-deploy (push) Successful in 1m5s
Reviewed-on: #31
2026-08-31 23:36:45 -06:00
jheaps a5b9c26902 Merge branch 'master' into feature/casino-holdem-sidebar-polish 2026-08-31 23:36:37 -06:00
jheapsandClaude Sonnet 5 9a5b974fda Add a rulings reference for the calls that come up mid-hand
The tool handles the money; the other half of dealing is the calls. A home-game
dealer who isn't a pro has to rule on a misdeal or a prematurely turned board
card with a table watching, and the usual outcome is an argument settled by
whoever is loudest.

A "Common rulings" link under the table opens a reference for exactly the
situations that come up: what counts as a misdeal, what to do when the fourth
board card gets turned before the betting is finished, and how acting out of
turn is normally treated. It follows the Tournament Directors Association
rules, and says plainly that house rules win and that a dispute gets settled
between hands rather than during one.

It's a sheet rather than a sixth sidebar panel — a dealer consults this rarely
and needs it immediately, which is the opposite of what a panel is for.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:35:59 -06:00
jheaps b563a56563 Merge pull request 'Put bets on the felt as chips instead of numbers in a box' (#29) from feature/casino-holdem-bet-chips into feature/casino-holdem-sidebar-polish
Reviewed-on: #29
2026-08-31 23:35:28 -06:00
jheapsandClaude Sonnet 5 72ee2fb2b7 Put bets on the felt as chips instead of numbers in a box
The seat puck showed a green figure that meant "what this player just bet"
during a street and "what they've put in this hand" between streets — two
different numbers wearing the same badge, sitting directly above their stack
size. Easy to misread, and nothing on the table looked like money.

Bets now sit on the felt between their player and the pot, as a chip coloured
by the largest denomination in the chip legend that fits the amount: a $30 bet
shows green against a $25 chip, a $2 bet shows white. When a street closes the
chips come off the felt and the pot badge keeps the money, the way they'd be
swept in. The puck's figure now always means the same thing — this player's
total for the hand.

On a phone the bet clings to its own puck instead of sitting on its own ring.
That isn't a preference: sweeping every radius between the seat ring and the
pot badge at 390px showed each one collides with either the pucks or the pot,
because the felt has no room for a third ring at that size.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:24:39 -06:00
jheapsandClaude Sonnet 5 ae927983e7 Tidy the sidebar down from five panels to four
Each round added a panel, and the sidebar became five identical dark accordions
with no hierarchy between them — which is the polish debt I've been pointing at
for a while without paying it down.

Session Standings and Hand History were always two views of the same log, so
they're one Session panel now, with subheads and the export and clear buttons
at the bottom where they act on both. Base pot amount is the pre-live-betting
way of working, so it moves into a "Without live betting" disclosure inside
Table Setup rather than sitting between two settings a dealer actually touches.
Panels are ordered by how often they're opened, and the hints are shorter.

Also stops "Small blind" wrapping onto two lines beside its input, which made
the pair look like a mistake rather than a pair.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:06:30 -06:00
jheapsandClaude Sonnet 5 4ceeed71a2 Add keyboard control for a dealer working on a laptop
The phone got gestures a few rounds back; the desktop still had a mouse and
nothing else. On a bar that gets tapped thirty times a hand, moving a hand to
the trackpad between every decision is the slow part.

F folds, C checks or calls, R opens the raise pad, A shoves, Enter confirms a
raise or moves the hand on, Esc backs out, U takes the last action back. Each
button wears its key in the corner, hidden on touch devices where it would just
be clutter on a button being tapped anyway.

The shortcuts press the same buttons a dealer would tap rather than calling the
betting engine themselves, so there's no second code path to drift out of step
with what the bar is actually offering — if a button isn't on screen, its key
does nothing.

Nothing fires while a name, amount or note is being typed, or while an overlay
owns the keyboard. Typing "Francesca" into a seat name must not fold the table.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:52:52 -06:00
jheapsandClaude Sonnet 5 95c523738c Let the dealer take back an action from the bar
An audit of what's accumulated turned up an inconsistency worth fixing: double-
tapping a seat to fold someone was undoable, but tapping Fold in the action bar
— the button a dealer actually uses all hand — was not. Nor was Call, Raise, or
dealing a street. The safest path through the tool was the shortcut, and the
main one had no way back.

Every action taken from the bar now goes through one place that records it
first, and the bar carries a standing Undo naming what it will take back:
"Ben's fold", "dealing the flop". Twelve steps deep, because a misclick usually
gets noticed a beat late and one level of undo would only cover the luckiest
case.

It's a control rather than a toast per action. A dealer taps this bar
constantly, and a toast after every fold is noise they'd learn to ignore —
which is precisely when they'd miss the one that mattered.

The history is discarded whenever a hand starts or settles, because those
snapshots describe a hand that no longer exists. The whole-hand Undo on the
toast already covers that boundary.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:37:03 -06:00
jheapsandClaude Sonnet 5 1fcc058dd8 Show how long the current player has been thinking, and how many are left
Dealing guides put the biggest time sink not in shuffling or spreading cards
but in players who haven't noticed the action is on them — and they list the
per-street count of players remaining as part of what a dealer announces.

The action bar now carries both. A stopwatch runs on whoever is to act, going
amber at thirty seconds and red at a minute, so a dealer can see at a glance
whether someone is genuinely deciding or has stopped paying attention. Nothing
happens when it grows; it is a prompt for the dealer, not a clock on the
player. Beside it is the count of players still in the hand.

The timer restarts only when the action actually moves, so re-rendering for an
unrelated reason doesn't put a thinking player back to zero, and it ticks by
repainting its own text rather than rebuilding the buttons under the dealer's
thumb. It stops when the hand ends or the bar hides.

Also stops the side-pot breakdown from crying wolf: it now waits for someone to
actually be all-in. Unequal amounts in front of players are normal mid-street —
the blinds alone are unequal — and layering those announced a side pot on every
hand before anyone had acted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:21:39 -06:00
jheapsandClaude Sonnet 5 ee25e2a4fa Show the side-pot breakdown before the showdown, not after
Dealing guides are consistent that the most common side-pot error isn't
miscounting the chips, it's failing to announce the breakdown clearly before
the showdown. The tool had the arithmetic right but only revealed it after the
hand, one layer at a time, as the wizard asked who won each.

The breakdown is now visible while the hand is still running: as soon as an
all-in splits the money, the action bar lists the main pot and each side pot
with the amount and who can win it, and the wizard repeats it under "Announce
before the showdown" before asking for a winner. A hand with a single
undivided pot shows nothing, so the line means something when it appears.

Chips nobody could call are called what they are. Money above the largest live
contribution was never contested — usually an overbet by someone who then
folded — so it's listed as "Uncalled, back to X" rather than padding a side
pot nobody can win. The wizard's leftover warning says the same thing instead
of telling the dealer to un-fold someone to win it, which was the wrong advice
for chips that should simply be pushed back.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:10:19 -06:00
jheapsandClaude Sonnet 5 dabf1100c9 Add double-tap and press-and-hold shortcuts to the seats
Folding someone meant opening their editor, finding the Fold box, ticking it,
and dismissing the sheet — four interactions for the thing that happens most
often in a hand.

Double-tap a seat to fold, and again to put them back. Press and hold for a
quick menu of everything else you do to one player: fold, all-in, sit out or
deal in, hand them the button, or open the full editor. Both are undoable the
same way the header buttons are, because a gesture is easier to fire by
accident than a button.

The single tap now waits out the double-tap window before opening the editor —
a fifth of a second of latency on opening a form, in exchange for a fold that
doesn't also fling a sheet up over the table. A press that moves is a drag, so
the hold is cancelled and swapping seats still works, and the puck shrinks
slightly while held so a press that's being counted looks different from one
that's being ignored.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 21:54:56 -06:00
jheapsandClaude Sonnet 5 0759d89d13 Pick hole cards from a grid instead of typing them
Typing "AH" into a two-character box is fine at a desk and miserable at a table:
it needs a keyboard, it accepts nonsense like "ZZ", and it happily deals the same
card to two players.

The two card fields become face-up card slots. Tapping one opens a 52-card grid
— four suit columns, thirteen rank rows, one tap per card — and picking the first
card moves straight on to the second, because a dealer picking hole cards is
always picking two. Cards already dealt elsewhere at the table are greyed out and
say who has them, so a duplicate is visibly out of play rather than silently
accepted.

Cards are still stored as the same two plain characters everything has always
read; the suit only becomes a symbol on its way to a human. That formatting is
now shared, so the table pucks, the seat editor, the pot wizard, the hand history
and the CSV all say A♥ K♦ rather than three of them saying AH KD.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 21:32:43 -06:00
jheaps 01a1728971 Merge pull request 'Live betting: tap the action instead of typing totals' (#21) from feature/casino-holdem-live-betting into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #21
2026-08-31 19:19:16 -06:00
jheapsandClaude Sonnet 5 7bedee2934 Track the betting live so the dealer taps instead of types
Until now a dealer kept the money in their head and typed each player's total
into a form. This adds the running hand: blinds post themselves, every seat has
a stack, and one tap per decision moves the action on.

The bar sits at the bottom of the screen where a thumb already is, and shows
only what's true right now — Fold, then Check or Call with the amount owed, then
Bet or Raise. Calling more than a player has left says "Call all-in" rather than
quoting a number they can't cover. Raising offers min, half pot, pot and all-in
before it offers a keypad, because a dealer calling "raise to sixty" wants a
button. When everyone has answered, the bar becomes Deal Flop, then Turn, River,
and finally Showdown, which hands off to the pot wizard that already existed.
Winnings go straight back in front of the winner, so the next hand's stacks are
already right.

The money hooks into what was already there rather than running beside it: a
street's chips sweep into each seat's amountOverride, the same field the pot
badge, the pot wizard, the session standings and the CSV export all read. So
nothing downstream changed, and a dealer who prefers the old typed workflow
still has it.

Tables saved before this exist without any of these fields, so load() fills
them in rather than making someone reset a table mid-session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 19:01:08 -06:00
jheaps 7143e4e701 Merge pull request 'Feature/casino holdem undo toasts' (#19) from feature/casino-holdem-undo-toasts into master
Build and Deploy / build-deploy (push) Successful in 1m15s
Reviewed-on: #19
2026-08-31 18:41:22 -06:00
jheaps e8797b8dc2 Merge branch 'master' into feature/casino-holdem-undo-toasts 2026-08-31 18:41:13 -06:00
jheaps 48bd3682f2 Merge pull request 'Make the four seat states readable across the table' (#18) from feature/casino-holdem-seat-legibility into feature/casino-holdem-undo-toasts
Reviewed-on: #18
2026-08-31 18:39:58 -06:00
jheapsandClaude Sonnet 5 1e1bdcccce Make the four seat states readable across the table
A seat could be live in the hand, folded, sitting out, or empty, and three of
those looked nearly the same: a plain dark puck, or a dimmed one. An unclaimed
chair was indistinguishable from a nameless player, and nothing positively said
"this player is in" — the dealer had to infer it from whether a dollar badge
happened to be showing.

Each state now has one look. Live gets a green edge. Folded gets a red edge and
a struck-through name, so it reads as out rather than merely faint. An empty
chair is the only dashed outline and says so. Sitting out just fades — and
fades less than it used to, since that player is still someone the dealer may
need to deal back in, and at 0.4 the name was nearly gone.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:36:54 -06:00
jheaps aee172a5cd Merge pull request 'Quick Split: chop a pot from your phone without setting up a table' (#15) from feature/casino-holdem-quick-split into master
Build and Deploy / build-deploy (push) Successful in 1m14s
Reviewed-on: #15
2026-08-31 18:33:05 -06:00
jheaps d206758729 Merge branch 'master' into feature/casino-holdem-quick-split 2026-08-31 18:32:56 -06:00
jheaps 4afc3649cc Merge pull request 'Make the dealer table usable on a phone' (#16) from feature/casino-holdem-mobile-table into feature/casino-holdem-quick-split
Reviewed-on: #16
2026-08-31 18:31:52 -06:00
jheaps 3866bdb335 Merge pull request 'Replace the browser confirm boxes with undo and in-page dialogs' (#17) from feature/casino-holdem-undo-toasts into feature/casino-holdem-mobile-table
Reviewed-on: #17
2026-08-31 18:31:40 -06:00
jheaps 71b33f8623 Merge pull request 'Give the Hold'em dealer a session record: hand history, CSV export and standings' (#14) from feature/casino-holdem-hand-history into master
Build and Deploy / build-deploy (push) Successful in 1m13s
Reviewed-on: #14
2026-08-31 18:31:19 -06:00
jheapsandClaude Sonnet 5 d01c67fc45 Replace the browser confirm boxes with undo and in-page dialogs
Three system confirm() dialogs sat in the middle of dealing. The worst was on
Next Hand — the most-pressed button in a session, asking "are you sure?" every
single time, stealing focus and, on a phone, taking over the screen.

Next Hand now just happens, and a toast says where the button landed with an
Undo beside it for the few seconds anyone would notice a misclick. The same
undo covers the pot wizard, which is the more valuable case: paying the wrong
player is a real mistake, and rolling it back restores the hand history entry
and the session standings along with the seats. The snapshot is in-memory only
— an undo is for the mistake you just made, not one from last night.

The two genuinely destructive actions keep a confirmation, but as an in-page
dialog that matches the table instead of the browser's own: Escape or the
backdrop cancels, Enter confirms, and on a phone the buttons go full width with
Cancel nearest the thumb.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:22:50 -06:00
jheapsandClaude Sonnet 5 a39675f975 Make the dealer table usable on a phone
Two things were broken on a narrow screen. The seat ring is drawn on a radius
measured from the table wrap, so at phone width the left- and right-most seats
hung off the edge of the viewport — at a ten-handed table two players were
simply not on screen. And tapping a seat opened an editor far enough down the
page that it looked like nothing had happened.

The ring is now inset by half a puck on phones, which keeps every seat on
screen, and the pucks are narrower there so neighbours stop overlapping where
the ring flattens out at the top and bottom. The seat editor becomes a bottom
sheet that slides up over the table: grip, Done button, tap the same seat again
to dismiss, table still visible above it. Its controls grow to thumb size, and
every field goes to 16px — below that iOS zooms the page on focus and the
dealer has to pinch back out mid-hand.

Header actions become full-width tap targets, and the page padding tightens to
give the table the width it needs. Nothing above 700px changes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:11:09 -06:00
jheapsandClaude Sonnet 5 870dee4a29 Add a phone-first Quick Split screen for chopping a pot mid-hand
The dealer table is a two-handed, big-screen tool. A dealer standing over a
chopped pot with a phone in one hand and players waiting needs an answer in
seconds, not a table to set up first — so /casino/texasholdem/quicksplit is
the whole job on one screen: type the pot, tap how many ways, tap the
smallest chip in play, read the payout. Nothing to configure, nothing saved.

It splits in whole chips rather than raw arithmetic, because a dealer can't
push half a chip across the felt: everyone gets the same stack and the
remainder is called out as odd chips going one at a time to the first
players left of the button. A pot that doesn't divide into the chosen
denomination says so instead of quietly hiding the difference.

Money formatting moves out of render.js into its own file so this screen can
use it without pulling in the whole seat/chip UI.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:53:18 -06:00
jheapsandClaude Sonnet 5 bcfc1f2f52 Track session standings so the dealer can settle up at the end of the night
The hand log answered "what happened in hand 12"; it couldn't answer "what
does Alice owe". Each resolved pot now records a per-seat ledger (what that
player put in, what they took down) alongside the payouts, and a Session
Standings panel sums it into one row per player — paid in, won, net, sorted
by who's up. The CSV export carries the same table, so one file covers both
the hand-by-hand trail and the cash-out numbers.

Keyed by seat id rather than name, so renaming or reseating a player doesn't
split their totals. Hands recorded before this change have no ledger and are
skipped rather than counted wrong.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:37:48 -06:00
jheapsandClaude Sonnet 5 d168fedf1f Add a CSV export for the Texas Hold'em hand history log
A dedicated dealer needs a paper trail that outlives the browser tab, not
just a scrollable in-app panel — the Export CSV button downloads the full
resolved-pot log (hand #, time, pot, payouts) for recordkeeping or a
disputed chip count after the session ends.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:26:11 -06:00
jheapsandClaude Sonnet 5 68e263f325 Add a persistent hand history log to the Texas Hold'em dealer tool
Give the dealer a running record of resolved pots — hand number, total
pot, who got paid what, and the time — instead of losing that
information the moment the pot wizard closes. It's the kind of audit
trail a real dealer needs when a chip count gets disputed after the
fact.

- state.js tracks state.handNumber and state.handHistory (capped at 200
  entries) and persists both through the existing localStorage save/load
  path, with backfill defaults for tables saved before this change.
- potWizard.js now hands its final payouts back to the caller instead of
  discarding them once "Start Next Hand" is clicked.
- main.js records a history entry (skipping hands where nobody actually
  won anything) before advancing to the next hand, and wires up a "Clear
  history" control that also resets the hand counter.
- A new sidebar panel lists history entries newest-first; the header
  shows a running "Hand #N" badge next to the existing subtitle.

Verified via Playwright MCP: seated two players, resolved a pot, and
confirmed the header's hand counter incremented, the history panel
listed the payout, the entry survived a full page reload (localStorage
persistence), and Clear History reset both the log and the counter. No
console errors during the flow.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3NiiUDimoKDJ8GjC3KL3x
2026-08-31 17:15:23 -06:00
jheaps 105a74bc51 Merge pull request 'Feature/casino texas holdem' (#13) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #13
2026-08-31 16:06:56 -06:00
jheaps dce7e0f688 Merge pull request 'Add dedicated-dealer mode and redesign the Hold'em table' (#12) from feature/casino-holdem-dealer-mode-polish into feature/casino-texas-holdem
Reviewed-on: #12
2026-08-31 15:58:17 -06:00
jheapsandClaude Sonnet 5 3851786b90 Add dedicated-dealer mode and redesign the table's visuals
Add a "Dedicated dealer (not a seat)" toggle for tables run by someone
outside the game: it hides every seat's dealer button while blinds keep
rotating automatically off the same internal position each hand.

Also gave the table a real casino look, based on reference felt/rail and
seat-pod patterns found via web search (huggingface.co/spaces/Estazz/
PokerGameDesigner, github.com/Tehes/poker): a richer radial felt gradient,
a gold piping ring inside a wood-toned rail, a subtle center watermark, a
pill-shaped chip-well pot badge, softer seat pucks with a pill-badge total
and red/black suit coloring on cards, and casino-chip-styled dealer/blind
badges (the dealer chip now dims to a faint disc on other seats and only
lights up on hover, decluttering the ring of ten clickable buttons down to
one visible button on the actual dealer).

Verified via Playwright MCP: toggle hides/reveals dealer chips correctly,
SB/BB still track it, no console errors.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 15:26:32 -06:00
jheaps 8e7d94e6f9 Merge pull request 'Fix Texas Hold'em dealer page: script timing crash and seat overlap' (#11) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m8s
Reviewed-on: #11
2026-08-31 15:03:37 -06:00
jheapsandClaude Sonnet 5 25bf67062a Fix Texas Hold'em dealer page: script timing crash and seat overlap
main.js ran an IIFE immediately, but the layout renders @section Scripts
inside <head> before <body> exists, so every getElementById() call hit
null. Wrapped init in DOMContentLoaded, matching the rest of the codebase
(e.g. ChessScripts/chessMain.js).

Separately, the original seat cards packed a full data-entry form (name,
toggles, 2 cards, 5 chip counters, $ override, custom fields) into each of
up to 24 positions around a circle — at any screen size, adjacent seats
overlapped badly. Replaced with compact seat "pucks" for the table view
(name, cards, total, blind/dealer badges) plus a dedicated seat editor
panel in the sidebar that opens for whichever seat is clicked.

Verified via Playwright MCP: load with no console errors, drag-and-drop
seat swap, and a 3-way side-pot resolution (short all-in capped correctly,
remainder awarded to the next-best hand) end to end.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 15:01:18 -06:00
jheaps 5d71572df6 Merge pull request 'Add Casino demo section with a Texas Hold'em dealer's pot-splitting assistant' (#10) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m16s
Reviewed-on: #10
2026-08-31 14:39:02 -06:00
jheapsandClaude Sonnet 5 98f95df8fb Add Casino demo section with a Texas Hold'em dealer's pot-splitting assistant
New /casino hub page (Texas Hold'em live, other table games marked coming
soon) and /casino/texasholdem: a drag-and-drop seating table with
auto-rotating blinds, per-seat chip/dollar contribution tracking, and a
side-pot resolution wizard that asks who won and who's next until every
dollar in the pot (including dead money from folds) is assigned.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 14:25:44 -06:00
jheaps b7c9227bb8 Merge pull request 'Learn fractional win-rate advantage instead of unbounded plus/minus' (#9) from feature/fractional-learned-weights into master
Build and Deploy / build-deploy (push) Successful in 1m25s
2026-08-31 13:43:09 -06:00
jheapsandClaude Sonnet 5 ddb309f6db Learn fractional win-rate advantage instead of unbounded plus/minus
Piece-square and feature weights now converge to a presence-weighted
fractional win-rate per cell (percent of games won with a piece there
minus percent lost), tracked via running visits/score sums instead of
an incrementally clamped nudge. The training signal is also scaled by
how much of a game's result is NOT already explained by the winner's
average material edge (via the standard centipawn win-probability
curve), so a piece no longer gets credit merely for existing on the
winning side of a material blowout in the endgame.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 13:40:57 -06:00
jheaps 2db4048ed2 Merge pull request 'Time out Stockfish process exit so finished self-play games cant wedge in the store' (#8) from fix/stockfish-dispose-hang into master
Build and Deploy / build-deploy (push) Successful in 1m18s
Reviewed-on: #8
2026-08-31 12:22:28 -06:00
jheapsandClaude Sonnet 5 2f53c2f0ed Time out Stockfish process exit so finished self-play games can't wedge in the store
DisposeAsync awaited WaitForExitAsync with no timeout; a Stockfish process that
doesn't exit on "quit" blocked GameStore's removal task forever, leaving the
finished game stuck in the store and cluttering the live-watch list indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 11:23:59 -06:00
jheaps 7562ded92a Merge pull request 'Bound how long the blog cache serves stale entries' (#7) from fix/blog-cache-staleness into master
Build and Deploy / build-deploy (push) Successful in 1m4s
Reviewed-on: #7
2026-08-26 17:24:29 -06:00
Josh-HeapsandClaude Opus 5 392b9ec212 Bound how long the blog cache serves stale entries
A failed fetch fell back to the cached value with no limit, so an unreachable
blog API kept the post list rendering while every uncached slug returned 404.
Stale entries now survive an hour past their TTL, then get dropped and logged
at error so the outage surfaces the same way for every key.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 17:22:51 -06:00
jheaps 769c7819e0 Merge pull request 'fix workflows take 2' (#6) from fix/workflows into master
Build and Deploy / build-deploy (push) Successful in 1m6s
Reviewed-on: #6
2026-08-26 15:46:12 -06:00
Josh-Heaps c048529654 fix workflows take 2 2026-08-26 15:45:29 -06:00
jheaps e8eb90a085 Merge pull request 'Put workflow in the right spot' (#4) from fix/workflows into master
Build and Deploy / build-deploy (push) Failing after 1m7s
Reviewed-on: #4
2026-08-26 15:39:47 -06:00
Josh-Heaps fc167ae2b0 Put workflow in the right spot 2026-08-26 15:39:11 -06:00
jheaps 088f05e0d2 Merge pull request 'add deploy workflow for gitea' (#3) from gitea/workflows into master
Build and Deploy / build (push) Failing after 34s
Build and Deploy / deploy (push) Skipped
Playwright Tests / playwright-tests (push) Failing after 50s
Reviewed-on: #3
2026-08-26 15:33:17 -06:00
Josh-Heaps 04dcae7993 add deploy workflow for gitea
.NET / build (pull_request) Successful in 50s
Playwright Tests / playwright-tests (pull_request) Failing after 54s
2026-08-26 15:15:51 -06:00
48 changed files with 6492 additions and 2980 deletions
@@ -1,4 +1,4 @@
# .gitea/workflows/deploy.yml
# .gitea/workflows/deploy.yml
name: Build and Deploy
on:
@@ -12,13 +12,17 @@ concurrency:
jobs:
build-deploy:
# Gitea's runner images are lean, so the toolchain is installed below rather
# than assumed. The glibc that libchess_engine.so links against comes from
# this image, not from the runner host — keep it matched to the prod server.
runs-on: ubuntu-latest
steps:
- name: Install deploy tools
- name: Install toolchain
run: |
apt-get update
apt-get install -y --no-install-recommends rsync openssh-client
apt-get install -y --no-install-recommends \
cmake build-essential rsync openssh-client
- name: Checkout
uses: actions/checkout@v4
@@ -29,10 +33,26 @@ jobs:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
- name: Build native chess engine (libchess_engine.so)
run: |
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/chess_engine/build
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
# Publishing the project rather than the solution keeps chess_engine.vcxproj
# (Windows-only MSBuild C++ targets) and the UiTests assemblies out of it.
- name: Publish
run: dotnet publish -c Release -o ./publish
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
- name: Verify payload
run: |
set -euo pipefail
test -f publish/Resources/libchess_engine.so \
|| { echo "libchess_engine.so missing from publish output"; exit 1; }
test -x publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt \
|| { echo "stockfish is not executable"; exit 1; }
- name: Prepare SSH
env:
@@ -47,6 +67,8 @@ jobs:
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# --exclude chess-data: never let --delete remove the learned-engine training
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
- name: Rsync to server
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
@@ -56,11 +78,11 @@ jobs:
run: |
set -euo pipefail
PORT="${SSH_PORT:-22}"
rsync -az --delete \
rsync -az --delete --exclude 'chess-data' \
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
- name: Restart service
- name: Reload and restart service
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
-296
View File
@@ -1,296 +0,0 @@
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace JoshHeaps.Net.UiTests;
/// <summary>
/// Exercises the acoustic-ranging pipeline against a simulated room. The DSP runs in the browser
/// because that is where it runs in production; these tests drive the same files the page loads,
/// so there is no second implementation to drift.
/// </summary>
[TestFixture]
public class EchoDspTests : PageTest
{
private TestConfiguration Config => TestConfiguration.Instance;
public override BrowserNewContextOptions ContextOptions() => Config.GetBrowserContextOptions();
[SetUp]
public async Task LoadPipeline()
{
await Page.GotoAsync(Config.Test.BaseUrl);
await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoDsp.js" });
await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoSim.js" });
}
[Test]
public async Task Matched_Filter_Finds_The_Chirp_Within_One_Sample()
{
var error = await Page.EvaluateAsync<double>("""
() => {
const sampleRate = 48000;
const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
const recording = new Float32Array(sampleRate);
for (let i = 0; i < recording.length; i++) recording[i] = (Math.random() * 2 - 1) * 0.02;
const offset = 12345;
for (let i = 0; i < chirp.length; i++) recording[offset + i] += chirp[i] * 0.3;
const peak = EchoDsp.findFirstPeak(EchoDsp.matchedFilterEnvelope(recording, chirp));
return Math.abs(peak.index - offset);
}
""");
Assert.That(error, Is.LessThan(1.0), "arrival should be located to within a sample");
}
[Test]
public async Task A_Tone_Cannot_Be_Located_But_A_Chirp_Can()
{
var ratios = await Page.EvaluateAsync<double[]>("""
() => {
const sampleRate = 48000;
const sidelobeRatio = template => {
const recording = new Float32Array(sampleRate / 2);
const offset = 8000;
for (let i = 0; i < template.length; i++) recording[offset + i] += template[i];
const envelope = EchoDsp.matchedFilterEnvelope(recording, template);
const peak = EchoDsp.maxInRange(envelope, 0, envelope.length);
let highest = 0;
for (let i = 0; i < envelope.length; i++) {
if (Math.abs(i - peak.index) < 200) continue;
highest = Math.max(highest, envelope[i]);
}
return highest / peak.value;
};
const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
const tone = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 5000, endHz: 5000 });
return [sidelobeRatio(chirp), sidelobeRatio(tone)];
}
""");
Assert.That(ratios[1], Is.GreaterThan(0.5), "a tone should correlate almost as well far from the true arrival");
Assert.That(ratios[0], Is.LessThan(0.25), "a chirp should give one unambiguous arrival");
}
[Test]
public async Task Clock_Offset_And_Pipeline_Latency_Cancel()
{
var distance = await Page.EvaluateAsync<double>("""
() => {
const sampleRate = 48000;
const speedOfSound = 343;
const flight = (4.2 / speedOfSound) * sampleRate;
const slot = 0.4 * sampleRate;
const offsetB = 987654;
const latencyA = 0.031 * sampleRate;
const latencyB = 0.128 * sampleRate;
return EchoDsp.pairDistance({
a1: latencyA,
a2: slot + latencyB + flight,
b1: offsetB + latencyA + flight,
b2: offsetB + slot + latencyB,
sampleRate,
speedOfSound
});
}
""");
Assert.That(distance, Is.EqualTo(4.2).Within(0.001));
}
[Test]
public async Task Collocated_Devices_Read_Zero_Before_Calibration()
{
var distance = await Page.EvaluateAsync<double>("""
() => {
const sampleRate = 48000;
const speedOfSound = 343;
const spacing = (0.19 / speedOfSound) * sampleRate;
const slot = 0.4 * sampleRate;
const latencyA = 0.04 * sampleRate;
const latencyB = 0.11 * sampleRate;
// Two tabs on one machine: one speaker, one microphone, so the self path and the
// cross path are the same physical distance.
return EchoDsp.pairDistance({
a1: latencyA + spacing,
a2: slot + latencyB + spacing,
b1: latencyA + spacing,
b2: slot + latencyB + spacing,
sampleRate,
speedOfSound
});
}
""");
Assert.That(distance, Is.EqualTo(0).Within(0.001));
}
[Test]
public async Task Speaker_To_Microphone_Spacing_Is_Added_Back()
{
var distances = await Page.EvaluateAsync<double[]>("""
() => {
const sampleRate = 48000;
const speedOfSound = 343;
const truth = 3.0;
const epsilonA = 0.18;
const epsilonB = 0.04;
const samples = metres => (metres / speedOfSound) * sampleRate;
const slot = 0.4 * sampleRate;
const peaks = {
a1: samples(epsilonA),
a2: slot + samples(truth),
b1: samples(truth),
b2: slot + samples(epsilonB),
sampleRate,
speedOfSound
};
return [
EchoDsp.pairDistance(peaks),
EchoDsp.pairDistance({ ...peaks, epsilonA, epsilonB })
];
}
""");
Assert.That(distances[0], Is.EqualTo(3.0 - 0.11).Within(0.005), "uncorrected range reads short");
Assert.That(distances[1], Is.EqualTo(3.0).Within(0.005), "correcting for spacing recovers the true range");
}
[Test]
public async Task Simulated_Room_Recovers_Distances_And_Layout()
{
var errors = await Page.EvaluateAsync<double[]>("""
() => {
const result = EchoSim.runRound({
positions: [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]]
});
return [EchoSim.worstDistanceError(result), EchoSim.worstPositionError(result), result.keep.length];
}
""");
Assert.That(errors[2], Is.EqualTo(5), "every device should survive a clean round");
Assert.That(errors[0], Is.LessThan(0.05), "worst pairwise range error");
Assert.That(errors[1], Is.LessThan(0.15), "worst recovered position error");
}
[Test]
public async Task A_Reflection_Louder_Than_The_Direct_Path_Does_Not_Win()
{
var errors = await Page.EvaluateAsync<double[]>("""
() => {
const measure = relativeThreshold => EchoSim.worstDistanceError(EchoSim.runRound({
positions: [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]],
reflections: [{ extraMetres: 1.8, gain: 5 }],
peakOptions: { relativeThreshold }
}));
return [measure(undefined), measure(0.5)];
}
""");
Assert.That(errors[0], Is.LessThan(0.05), "the first arrival is the distance, not the loudest one");
Assert.That(errors[1], Is.GreaterThan(1.5),
"a threshold high enough to miss the direct path must measure the reflection instead — this is what the default guards against");
}
[Test]
public async Task Echoes_Inside_The_Correlation_Lobe_Bound_The_Accuracy()
{
var errors = await Page.EvaluateAsync<double[]>("""
() => {
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]];
const worst = reflectionExtraRange =>
EchoSim.worstDistanceError(EchoSim.runRound({ positions, reflectionExtraRange }));
return [worst([0.4, 4.0]), worst([0.08, 0.4])];
}
""");
Assert.That(errors[0], Is.LessThan(0.01), "echoes well clear of the direct arrival are rejected outright");
Assert.That(errors[1], Is.LessThan(0.15),
"echoes arriving inside the correlation lobe cannot be separated and bias the range — this bounds what a device resting on a hard surface can achieve");
}
[Test]
public async Task A_Bad_Measurement_Is_Rejected_And_The_Layout_Survives()
{
var outcome = await Page.EvaluateAsync<double[]>("""
() => {
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.6, 1.3]];
const config = EchoSim.buildConfiguration({ positions });
const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
reports[3].peaks[0] += 9000;
const solved = EchoDsp.solveRound(reports, { speedOfSound: config.speedOfSound });
const truth = solved.keep.map(index => positions[index]);
const aligned = EchoDsp.alignToReference(solved.points, truth);
const worst = Math.max(...aligned.map((point, i) => EchoSim.separation(point, truth[i])));
const brokenPairSurvived = solved.keep.includes(0) && solved.keep.includes(3);
return [solved.keep.length, brokenPairSurvived ? 1 : 0, worst];
}
""");
Assert.That(outcome[0], Is.EqualTo(4), "exactly one endpoint of the bad pair should be dropped");
Assert.That(outcome[1], Is.EqualTo(0), "the impossible pair must not survive");
Assert.That(outcome[2], Is.LessThan(0.2), "the remaining layout should be unpoisoned");
}
[Test]
public async Task Alignment_Undoes_An_Arbitrary_Rotation_And_Mirror()
{
var errors = await Page.EvaluateAsync<double[]>("""
() => {
const reference = [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]];
const scramble = (points, angle, mirror) => points.map(([x, y]) => {
const mx = x * mirror;
return [mx * Math.cos(angle) - y * Math.sin(angle) + 11, mx * Math.sin(angle) + y * Math.cos(angle) - 4];
});
const worst = mirror => {
const aligned = EchoDsp.alignToReference(scramble(reference, 0.9, mirror), reference);
return Math.max(...aligned.map((point, i) => EchoSim.separation(point, reference[i])));
};
return [worst(1), worst(-1)];
}
""");
Assert.That(errors[0], Is.LessThan(1e-9), "rotation and translation should be recovered exactly");
Assert.That(errors[1], Is.LessThan(1e-9), "a mirrored solve should be un-mirrored onto the reference");
}
[Test]
public async Task Consecutive_Frames_Do_Not_Rotate_Or_Flip()
{
var drift = await Page.EvaluateAsync<double>("""
() => {
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9]];
const first = EchoSim.runRound({ positions, seed: 11 });
const previous = new Array(positions.length).fill(null);
first.keep.forEach((device, i) => { previous[device] = first.points[i]; });
const config = EchoSim.buildConfiguration({ positions, seed: 22 });
const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
const second = EchoDsp.solveRound(reports, {
speedOfSound: config.speedOfSound,
previousPoints: previous
});
return Math.max(...second.keep.map((device, i) => EchoSim.separation(second.points[i], previous[device])));
}
""");
Assert.That(drift, Is.LessThan(0.3), "a stationary room should not move between frames");
}
}
-244
View File
@@ -1,244 +0,0 @@
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace JoshHeaps.Net.UiTests;
/// <summary>
/// Drives two real browsers through a real room: the hub, the round scheduler, slot rotation,
/// detection and the solve all run unchanged. Only the microphone is synthetic, so the answer is
/// known in advance — this is everything except the acoustics.
/// </summary>
[TestFixture]
public class EchoRoomTests : PlaywrightTest
{
private const double TargetMetres = 2.5;
private IPlaywright? _playwright;
private IBrowser? _browser;
private readonly List<IBrowserContext> _contexts = [];
private TestConfiguration Config => TestConfiguration.Instance;
[OneTimeSetUp]
public async Task LaunchBrowser()
{
// Its own Playwright instance and browser: the fake-media launch flags have to be set at
// launch time, and the fixture-managed browser is already running by the time tests start.
_playwright = await Microsoft.Playwright.Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true,
Args =
[
"--use-fake-ui-for-media-stream",
"--use-fake-device-for-media-stream",
"--autoplay-policy=no-user-gesture-required"
]
});
}
[OneTimeTearDown]
public async Task CloseBrowser()
{
if (_browser is not null) await _browser.CloseAsync();
_playwright?.Dispose();
}
[TearDown]
public async Task CloseContexts()
{
foreach (var context in _contexts) await context.CloseAsync();
_contexts.Clear();
}
[Test]
public async Task Two_Devices_Measure_The_Distance_Between_Them()
{
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
var first = await NewDeviceAsync(roomCode, "laptop");
var second = await NewDeviceAsync(roomCode, "phone");
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
var measured = await WaitForMeasurementAsync(first);
var alsoMeasured = await WaitForMeasurementAsync(second);
Assert.That(measured, Is.EqualTo(TargetMetres).Within(0.05), "the first device's range");
Assert.That(alsoMeasured, Is.EqualTo(TargetMetres).Within(0.05), "both devices should agree");
}
[Test]
public async Task A_Device_Leaving_Stops_The_Rounds_And_Rejoining_Resumes_Them()
{
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
var first = await NewDeviceAsync(roomCode, "laptop");
var second = await NewDeviceAsync(roomCode, "phone");
await WaitForMeasurementAsync(first);
await second.ClickAsync("#echoLeave");
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(1);
await Expect(first.Locator("#echoStatus")).ToContainTextAsync("Waiting for a second device");
await second.ClickAsync("#echoJoin");
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
Assert.That(await WaitForMeasurementAsync(first), Is.EqualTo(TargetMetres).Within(0.05));
}
/// <summary>
/// A stalled main thread is the two-tabs-on-one-machine failure: only one tab is visible, so the
/// other gets throttled and its round handling runs late. A late chirp attributed to the wrong
/// slot yields a plausible-looking but completely wrong range, so the requirement is not "always
/// measures" — it is "never reports a wrong answer". A round it cannot hit must be sat out.
/// </summary>
[Test]
public async Task A_Stalled_Device_Sits_Rounds_Out_Instead_Of_Reporting_Nonsense()
{
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
var first = await NewDeviceAsync(roomCode, "laptop");
var second = await NewDeviceAsync(roomCode, "phone", stallMilliseconds: 900);
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
await first.EvaluateAsync("() => { window.__seen = []; }");
await first.EvaluateAsync("""
() => {
const original = EchoPage.renderSolved.bind(EchoPage);
EchoPage.renderSolved = update => {
const range = update.solved?.matrix?.[0]?.[1];
if (range != null) window.__seen.push(range);
return original(update);
};
}
""");
await first.WaitForTimeoutAsync(20000);
var seen = await first.EvaluateAsync<double[]>("() => window.__seen");
var satOut = await second.EvaluateAsync<int>("() => EchoSession.skippedRounds");
TestContext.Out.WriteLine($"reported ranges: {string.Join(", ", seen.Select(r => r.ToString("0.000")))}, sat out: {satOut}");
Assert.That(satOut, Is.GreaterThan(0),
"the stall must actually have cost the device some slots, or this test proves nothing");
Assert.That(seen, Is.Not.Empty, "a stalled peer should still let some rounds through");
Assert.That(seen, Is.All.EqualTo(TargetMetres).Within(0.05),
"every range that gets reported must be right — a stalled device must sit the round out, not chirp late");
}
[Test]
public async Task The_Capture_Worklet_Keeps_A_Continuous_Readable_Stream()
{
var page = await NewDeviceAsync($"T{Random.Shared.Next(1000, 9999)}", "laptop", fakeMicrophone: false);
await page.WaitForFunctionAsync(
"() => EchoAudio.highestFrame > 48000",
null,
new PageWaitForFunctionOptions { Timeout = 15000, PollingInterval = 100 });
var capture = await page.EvaluateAsync<double[]>("""
() => [
EchoAudio.context.sampleRate,
EchoAudio.warnings.length,
EchoAudio.read(EchoAudio.highestFrame - 24000, 24000)?.length ?? 0,
EchoAudio.read(EchoAudio.highestFrame + 1000, 100) === null ? 1 : 0,
Math.abs(EchoAudio.frameAt(EchoAudio.context.currentTime) - EchoAudio.highestFrame)
]
""");
Assert.That(capture[0], Is.EqualTo(48000), "the pipeline assumes it got the rate it asked for");
Assert.That(capture[1], Is.EqualTo(0), "a clean fake device should raise no capture warnings");
Assert.That(capture[2], Is.EqualTo(24000), "recent audio must be readable out of the ring");
Assert.That(capture[3], Is.EqualTo(1), "reads past the captured end must fail rather than return silence");
Assert.That(capture[4], Is.LessThan(48000),
"the frame index and the context clock must stay in the same domain — a scheduled playback time is converted straight into a recording position");
}
/// <summary>
/// A page joined to the room with its microphone replaced by a synthesizer. Every slot's chirp
/// is placed where a room of this geometry would put it, including a different unknown output
/// latency per slot so the cancellation is actually exercised.
/// </summary>
private async Task<IPage> NewDeviceAsync(
string roomCode,
string name,
bool fakeMicrophone = true,
int stallMilliseconds = 0)
{
var context = await _browser!.NewContextAsync(new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true,
Permissions = ["microphone"]
});
_contexts.Add(context);
var page = await context.NewPageAsync();
page.Console += (_, message) =>
{
if (message.Type == "error") TestContext.Out.WriteLine($"[{name} console] {message.Text}");
};
await page.GotoAsync($"{Config.Test.BaseUrl}/echo?room={roomCode}");
await page.FillAsync("#echoName", name);
if (fakeMicrophone) await page.EvaluateAsync(FakeMicrophoneScript, TargetMetres);
if (stallMilliseconds > 0) await page.EvaluateAsync(StallScript, stallMilliseconds);
await page.ClickAsync("#echoJoin");
return page;
}
/// <summary>Blocks the main thread on every round announcement, the way a throttled tab does.</summary>
private const string StallScript = """
stallMs => {
const original = EchoSession.handleRoundStarting.bind(EchoSession);
EchoSession.handleRoundStarting = schedule => {
const until = performance.now() + stallMs;
while (performance.now() < until) { /* hold the thread */ }
return original(schedule);
};
}
""";
private static async Task<double> WaitForMeasurementAsync(IPage page)
{
await page.WaitForFunctionAsync(
"() => EchoPage.lastSolved?.solved?.matrix?.[0]?.[1] != null",
null,
new PageWaitForFunctionOptions { Timeout = 30000, PollingInterval = 250 });
return await page.EvaluateAsync<double>("() => EchoPage.lastSolved.solved.matrix[0][1]");
}
/// <summary>
/// Synthesizes what the microphone would have heard for this round. The device's own chirp is
/// placed at the frame it was actually scheduled for rather than at its nominal slot position,
/// so any drift between "when the round said to play" and "when playback was really booked"
/// reaches the detector instead of being papered over by the harness.
/// </summary>
private const string FakeMicrophoneScript = """
targetMetres => {
const speedOfSound = 343;
const epsilonMetres = 0.08;
const latencyBySlot = [1400, 5200, 2600, 7100, 900, 4300, 3300, 6000];
EchoAudio.read = (startFrame, length) => {
const round = EchoSession.pending ?? EchoSession.lastRound;
if (!round) return null;
const chirp = EchoSession.chirp;
const toSamples = metres => Math.round((metres / speedOfSound) * round.sampleRate);
const recording = new Float32Array(length);
round.schedule.slotOrder.forEach((_, slot) => {
const own = slot === round.ownSlot;
const origin = own
? round.scheduledFrame - round.windowStart
: round.leadInSamples + slot * round.slotSamples;
const at = origin + latencyBySlot[slot] + toSamples(own ? epsilonMetres : targetMetres);
const amplitude = own ? 1.0 : 0.25;
for (let i = 0; i < chirp.length && at + i < length; i++) recording[at + i] += chirp[i] * amplitude;
});
for (let i = 0; i < length; i++) recording[i] += (Math.random() * 2 - 1) * 0.01;
return recording;
};
}
""";
}
-52
View File
@@ -1,52 +0,0 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Hubs;
/// <summary>
/// Membership and peak reporting for acoustic ranging rooms. Nothing that arrives here is audio:
/// a report is a handful of sample indices, and the geometry is solved on the clients.
/// </summary>
public class EchoHub(IEchoRoomStore rooms) : Hub
{
/// <summary>Join (or create) a room and receive an id plus the current roster.</summary>
public async Task<EchoJoinResult> JoinRoom(string roomCode, string displayName, int sampleRate)
{
var result = rooms.Join(roomCode, Context.ConnectionId, displayName, sampleRate);
await Groups.AddToGroupAsync(Context.ConnectionId, GroupFor(result.RoomCode));
await Clients.Group(GroupFor(result.RoomCode)).SendAsync("RoomChanged", result.Room);
return result;
}
/// <summary>File this device's arrival indices for the room's open round.</summary>
public bool ReportRound(EchoPeakReport report) => rooms.Report(Context.ConnectionId, report);
/// <summary>
/// Server clock, for estimating each device's offset from it. Only needs to be good to a few
/// tens of milliseconds: it decides which chirp is whose, never how far away anything is.
/// </summary>
public long ServerTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
public async Task<bool> LeaveRoom() => await RemoveFromRoom();
public override async Task OnDisconnectedAsync(Exception? exception)
{
await RemoveFromRoom();
await base.OnDisconnectedAsync(exception);
}
internal static string GroupFor(string roomCode) => $"echo:{roomCode.ToUpperInvariant()}";
private async Task<bool> RemoveFromRoom()
{
var (roomCode, room) = rooms.Leave(Context.ConnectionId);
if (roomCode is null) return false;
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupFor(roomCode));
if (room is not null) await Clients.Group(GroupFor(roomCode)).SendAsync("RoomChanged", room);
return true;
}
}
-84
View File
@@ -1,84 +0,0 @@
namespace JoshHeaps.Net.Models;
/// <summary>A device taking part in a ranging room.</summary>
public sealed class EchoDevice
{
public required string DeviceId { get; init; }
public required string ConnectionId { get; init; }
public required string DisplayName { get; set; }
public int SampleRate { get; set; }
}
/// <summary>
/// One chirp cycle: every device plays in turn, and every device listens to the whole thing.
/// </summary>
public sealed class EchoRound
{
public required string RoundId { get; init; }
public required string[] SlotOrder { get; init; }
public int SlotMilliseconds { get; init; }
public DateTimeOffset Deadline { get; init; }
public Dictionary<string, EchoPeakReport> Reports { get; } = [];
}
/// <summary>
/// What one device heard. Peaks are fractional sample indices into that device's own continuous
/// recording, one per slot, null where a chirp was not detected. Nothing else is ever uploaded.
/// </summary>
public sealed class EchoPeakReport
{
public required string DeviceId { get; init; }
public required string RoundId { get; init; }
public int Slot { get; init; }
public int SampleRate { get; init; }
public double Epsilon { get; init; }
public double?[] Peaks { get; init; } = [];
}
public sealed class EchoJoinResult
{
public required string DeviceId { get; init; }
public required string RoomCode { get; init; }
public required EchoRoomSnapshot Room { get; init; }
}
public sealed class EchoRoomSnapshot
{
public required string RoomCode { get; init; }
public required EchoDeviceSnapshot[] Devices { get; init; }
}
public sealed class EchoDeviceSnapshot
{
public required string DeviceId { get; init; }
public required string DisplayName { get; init; }
public int SampleRate { get; init; }
}
/// <summary>Tells every device when to chirp: its own slot index and how long a slot lasts.</summary>
public sealed class EchoRoundSchedule
{
public required string RoundId { get; init; }
public required string[] SlotOrder { get; init; }
public int SlotMilliseconds { get; init; }
public int TailMilliseconds { get; init; }
/// <summary>
/// When slot zero should sound, in server time, set far enough ahead that every device can
/// receive the message and book the playback before it arrives. Devices schedule against this
/// rather than against message arrival, so one slow client cannot drag its chirp into another
/// device's slot and invalidate the round for everybody.
/// </summary>
public long StartsAtUnixMs { get; init; }
}
/// <summary>
/// Every device's peaks for one round, broadcast unchanged. Each client solves the geometry itself
/// so the server never needs the ranging maths.
/// </summary>
public sealed class EchoRoundResult
{
public required string RoundId { get; init; }
public required string[] SlotOrder { get; init; }
public required EchoPeakReport[] Reports { get; init; }
}
+58
View File
@@ -0,0 +1,58 @@
@page
@model JoshHeaps.Net.Pages.CasinoModel
@{
ViewData["Title"] = "Casino Games";
Layout = "_Layout";
}
<div class="casino-container">
<h1 class="casino-header">Casino Games</h1>
<p class="casino-subtitle">Tools for running table games without doing the math yourself</p>
<div class="games-grid">
<a href="/casino/texasholdem" class="game-card">
<div class="game-icon">🃏</div>
<div class="game-title">Texas Hold'em</div>
<div class="game-description">
A dealer's assistant: seat players around the table, track blinds, chips and
all-ins, then split main and side pots automatically.
</div>
</a>
<a href="/casino/texasholdem/quicksplit" class="game-card">
<div class="game-icon">➗</div>
<div class="game-title">Quick Split</div>
<div class="game-description">
Chop a pot in seconds on your phone — no table setup, just the amount,
how many players are splitting it, and the smallest chip in play.
</div>
</a>
<div class="game-card disabled">
<div class="game-icon">🎲</div>
<div class="game-title">Craps</div>
<div class="game-description">Payout calculator for the table.</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="game-card disabled">
<div class="game-icon">🎡</div>
<div class="game-title">Roulette</div>
<div class="game-description">Bet tracking and payout reference.</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="game-card disabled">
<div class="game-icon">🂡</div>
<div class="game-title">Blackjack</div>
<div class="game-description">Multi-hand dealer tracker.</div>
<div class="coming-soon">Coming soon...</div>
</div>
</div>
<a href="/" class="home-button">← Back to Home</a>
</div>
@section Styles {
<link href="/css/casino/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class EchoModel : PageModel
public class CasinoModel : PageModel
{
public void OnGet()
{
@@ -0,0 +1,378 @@
@page
@model JoshHeaps.Net.Pages.Casino.TexasHoldemModel
@{
ViewData["Title"] = "Texas Hold'em Dealer";
Layout = "_Layout";
}
<div id="holdemApp">
<header id="holdemHeader">
<div id="holdemHeaderLeft">
<h1>Texas Hold'em Dealer</h1>
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
</div>
<div id="holdemHeaderActions">
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
</div>
</header>
<div id="holdemLayout">
<section id="tableSection">
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="tableWatermark">Texas Hold'em</div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
</div>
</div>
<div id="betsLayer" aria-hidden="true"></div>
<div id="seatsLayer"></div>
</div>
<p id="tableHint" class="tableHint"></p>
<p class="tableHintActions">
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
</p>
</section>
<aside id="holdemSidebar">
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatEditorHeader">
<h2 id="seatEditorTitle">Select a seat</h2>
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
</div>
<div id="seatEditorBody">
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
</div>
</section>
<details class="sidebarPanel" open>
<summary>Table Setup</summary>
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
</label>
<label class="fieldRow">
<span>Blinds move</span>
<select id="directionSelect">
<option value="clockwise" selected>Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</label>
<label class="fieldRow">
<span>Dedicated dealer (not a seat)</span>
<input type="checkbox" id="dedicatedDealerToggle" />
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
<input type="number" id="smallBlindInput" min="0" step="0.5" />
</label>
<label class="fieldRow">
<span>Big blind</span>
<input type="number" id="bigBlindInput" min="0" step="0.5" />
</label>
</div>
<label class="fieldRow">
<span>Starting stack ($)</span>
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
</label>
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
</div>
</details>
</div>
</details>
<details class="sidebarPanel">
<summary>Session</summary>
<div class="panelBody">
<h3 class="panelSubhead">Standings</h3>
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span>In</span>
<span>Won</span>
<span>Net</span>
</div>
<div id="standingsList"></div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
<h3 class="panelSubhead">Hands</h3>
<div id="handHistoryList"></div>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
</div>
</details>
<details class="sidebarPanel">
<summary>Chip Legend</summary>
<div class="panelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
</div>
</details>
<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>
</div>
</details>
</aside>
</div>
</div>
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
<div class="seatPuckStack"></div>
</div>
</template>
<template id="seatEditorTemplate">
<div class="seatEditorForm">
<label class="fieldRow">
<span>Name</span>
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
</label>
<div class="seatCardToggles">
<label class="inHandToggle" title="This seat is playing the current hand">
<input type="checkbox" class="inHandCheckbox" />
<span>In hand</span>
</label>
<label class="foldToggle" title="Folded">
<input type="checkbox" class="foldCheckbox" />
<span>Fold</span>
</label>
</div>
<div class="seatCards">
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
</div>
</template>
<template id="chipLegendRowTemplate">
<div class="chipLegendRow">
<input type="color" class="chipColorInput" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
</div>
</template>
<template id="chipCountTemplate">
<label class="chipCountRow">
<span class="chipSwatch"></span>
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
</label>
</template>
<template id="customFieldTemplate">
<div class="customFieldRow">
<input type="text" class="customFieldLabel" placeholder="Label" maxlength="20" />
<input type="text" class="customFieldValue" placeholder="Value" maxlength="40" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeFieldBtn">×</button>
</div>
</template>
<template id="standingsRowTemplate">
<div class="standingsRow">
<span class="standingsName"></span>
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
</div>
</template>
<template id="handHistoryRowTemplate">
<div class="handHistoryRow">
<div class="handHistoryRowHeader">
<span class="handHistoryHandNumber"></span>
<span class="handHistoryPot"></span>
<span class="handHistoryTime"></span>
</div>
<div class="handHistoryPayouts"></div>
</div>
</template>
<div id="addChipsOverlay" hidden>
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="addChipsHeader">
<h2 id="addChipsTitle"></h2>
<span id="addChipsSubtitle"></span>
</div>
<div id="addChipsQuick"></div>
<div class="addChipsEntry">
<span class="addChipsCurrency">$</span>
<input type="text" id="addChipsInput" inputmode="decimal" autocomplete="off" aria-label="Amount to add" />
</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>
</div>
</div>
</div>
<div id="rulingsOverlay" hidden>
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
<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>
</div>
<p class="rulingsIntro">
The conventions most rooms follow, for the calls that come up mid-hand.
Your house rules always win — and settle a dispute before the next hand,
not during it.
</p>
<section class="ruling">
<h3>Redeal it — misdeal</h3>
<ul>
<li>The first card goes to the wrong seat.</li>
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
<li>Two or more boxed cards turn up on the initial deal.</li>
<li>Either of the first two cards is exposed by dealer error.</li>
</ul>
</section>
<section class="ruling">
<h3>A board card turned too early</h3>
<p>
If the fourth board card gets turned before the betting round is finished,
that card is out of play for the round. Finish the betting, then burn and
turn what would have been the fifth card in the fourth card's place.
</p>
</section>
<section class="ruling">
<h3>Acting out of turn</h3>
<p>
A one-off is usually just backed up if no one has acted behind them.
Doing it repeatedly is what earns a penalty.
</p>
</section>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
</p>
</div>
</div>
<div id="seatMenuOverlay" hidden>
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatMenuHeader">
<h2 id="seatMenuTitle"></h2>
<span id="seatMenuSubtitle"></span>
</div>
<div id="seatMenuActions"></div>
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
<div id="cardPickerOverlay" hidden>
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<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>
</div>
<div id="cardPickerSlots"></div>
<div id="cardPickerGrid"></div>
</div>
</div>
<div id="actionBar" aria-live="polite" hidden></div>
<div id="toastHost" aria-live="polite"></div>
<div id="confirmOverlay" hidden>
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
<div class="confirmActions">
<button id="confirmCancelBtn" 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 class="potWizardHeader">
<h2>Resolve the Pot</h2>
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
</div>
<div id="potWizardBody"></div>
</div>
</div>
@section Styles {
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
<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/main.js?v=@ViewData["cssVersion"]"></script>
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Casino
{
public class TexasHoldemModel : PageModel
{
public void OnGet()
{
}
}
}
@@ -0,0 +1,60 @@
@page
@model JoshHeaps.Net.Pages.Casino.TexasHoldem.QuickSplitModel
@{
ViewData["Title"] = "Quick Split";
Layout = "_Layout";
}
<div id="splitApp">
<header id="splitHeader">
<h1>Quick Split</h1>
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
</header>
<div id="splitResult" aria-live="polite">
<div id="splitResultLabel">Each player gets</div>
<div id="splitResultAmount">$0.00</div>
<div id="splitOddNote" hidden></div>
<div id="splitDustNote" hidden></div>
</div>
<section class="splitField">
<label for="potInput" class="splitLabel">Pot</label>
<div class="potInputWrap">
<span class="potCurrency">$</span>
<input type="text" id="potInput" inputmode="decimal" autocomplete="off"
placeholder="0" aria-label="Pot amount" />
<button type="button" id="clearPotBtn" class="clearPotBtn" aria-label="Clear pot amount" hidden>✕</button>
</div>
</section>
<section class="splitField">
<span class="splitLabel" id="waysLabel">Split between</span>
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
</section>
<section class="splitField">
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
</section>
<div id="splitActions">
<button type="button" id="resetSplitBtn" class="splitBtn splitBtnGhost">Clear</button>
<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>
</div>
@section Styles {
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@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/quickSplit.js?v=@ViewData["cssVersion"]"></script>
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Casino.TexasHoldem
{
public class QuickSplitModel : PageModel
{
public void OnGet()
{
}
}
}
-70
View File
@@ -1,70 +0,0 @@
@page
@model JoshHeaps.Net.Pages.EchoModel
@{
Layout = "_Layout";
ViewData["Title"] = "Echo";
}
<div id="echoHeader">
<h1>Echo</h1>
<p class="echo-blurb">
Two devices measure the distance between them by chirping at each other and timing the answer.
Each one records continuously and only ever compares arrivals inside its own recording, so no
clock synchronisation is needed and the unknown audio latency cancels out.
</p>
<p class="echo-privacy">Only sample numbers leave your device. The recording never does.</p>
</div>
<div id="echoControls">
<label>Room <input id="echoRoom" type="text" maxlength="6" autocomplete="off" spellcheck="false" /></label>
<label>Name <input id="echoName" type="text" maxlength="16" placeholder="this device" autocomplete="off" /></label>
<label>
Speaker to mic (m)
<input id="echoEpsilon" type="number" min="0" max="0.5" step="0.01" />
</label>
<button id="echoJoin">Join and listen</button>
<button id="echoLeave" hidden>Leave</button>
</div>
<p id="echoStatus" class="echo-status">Pick a room, then open the same room on a second device.</p>
<p class="echo-share">Join link: <a id="echoShareLink" href="#"></a></p>
<div id="echoWarnings"></div>
<div id="echoReadout" class="echo-readout"><span class="echo-readout-idle">not measuring</span></div>
<div id="echoPanels">
<section class="echo-panel">
<h2>Devices</h2>
<ul id="echoRoster"></ul>
</section>
<section class="echo-panel">
<h2>Ranges</h2>
<div id="echoPairs"></div>
</section>
<section class="echo-panel">
<h2>Timing</h2>
<p class="echo-hint">How far each chirp landed from its slot. Steady means the arrivals are being matched to the right devices.</p>
<div id="echoDiagnostics"></div>
</section>
</div>
<section class="echo-panel echo-panel-wide">
<h2>Matched filter</h2>
<p class="echo-hint">Each spike is a chirp arriving. The bright one is this device hearing itself.</p>
<canvas id="echoTrace"></canvas>
</section>
<a class="echo-back" href="/">&larr; Back</a>
@section Scripts {
<script src="~/js/signalr/signalr.min.js"></script>
<script src="~/js/EchoScripts/EchoDsp.js"></script>
<script src="~/js/EchoScripts/EchoAudio.js"></script>
<script src="~/js/EchoScripts/EchoSession.js"></script>
<script src="~/js/EchoScripts/echoMain.js"></script>
}
@section Styles {
<link rel="stylesheet" href="~/css/variables.css?v=@ViewData["cssVersion"]" />
<link rel="stylesheet" href="~/css/echo/echo.css?v=@ViewData["cssVersion"]" />
}
+1
View File
@@ -64,6 +64,7 @@
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
<button class="demoButton" onclick="window.location.href='/casino'">Casino Games</button>
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
<button id="MoreFiller" class="demoButton">More coming soon...</button>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
@{
Layout = null;
ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache
ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
}
<!DOCTYPE html>
-9
View File
@@ -1,7 +1,6 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
@@ -31,12 +30,6 @@ builder.Services.AddSingleton<IGameStore, GameStore>();
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
builder.Services.AddSingleton<AutoTrainingSettings>();
builder.Services.Configure<EchoRoundSettings>(configuration.GetSection(EchoRoundSettings.SectionName));
builder.Services.AddSingleton(provider =>
provider.GetRequiredService<IOptions<EchoRoundSettings>>().Value);
builder.Services.AddSingleton<IEchoRoomStore, EchoRoomStore>();
builder.Services.AddHostedService<EchoRoundService>();
if (!builder.Environment.IsDevelopment())
{
builder.Services.AddHostedService<AutoIpUpdateService>();
@@ -79,6 +72,4 @@ app.MapControllers();
app.MapHub<ChessHub>("/chessHub");
app.MapHub<EchoHub>("/echoHub");
app.Run();
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Text.Json;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
@@ -65,7 +66,9 @@ public class BlogService : IBlogService
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
{
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
_cache.TryGetValue(key, out var entry);
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
return (T?)entry.Value;
var result = await factory();
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
if (result is not null)
{
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
return result;
}
else if (entry is not null)
return entry is null ? null : ServeStale<T>(key, entry);
}
// Stale entries are dropped once the grace window closes so that a dead API fails the same
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
// the posts themselves, which reads as a site bug rather than an outage.
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
{
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
{
// API unreachable — serve stale cache
_logger.LogWarning("Serving stale cache for key {Key}", key);
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
return (T?)entry.Value;
}
return result;
_cache.TryRemove(key, out _);
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
return null;
}
public void ClearCache()
@@ -1,189 +0,0 @@
using System.Collections.Concurrent;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// In-memory ranging rooms. Singleton: rooms are process-wide and short-lived, and the site runs
/// as a single instance, so there is nothing to persist and no backplane to coordinate.
/// </summary>
public sealed class EchoRoomStore : IEchoRoomStore
{
private sealed class Room
{
public required string Code { get; init; }
public List<EchoDevice> Devices { get; } = [];
public EchoRound? Round { get; set; }
public int RoundCounter { get; set; }
public DateTimeOffset LastActivity { get; set; } = DateTimeOffset.UtcNow;
}
private readonly ConcurrentDictionary<string, Room> _rooms = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _roomsByConnection = [];
public EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate)
{
var room = _rooms.GetOrAdd(roomCode, code => new Room { Code = code });
var device = new EchoDevice
{
DeviceId = Guid.NewGuid().ToString("N")[..8],
ConnectionId = connectionId,
DisplayName = displayName,
SampleRate = sampleRate
};
lock (room)
{
room.Devices.RemoveAll(existing => existing.ConnectionId == connectionId);
room.Devices.Add(device);
room.LastActivity = DateTimeOffset.UtcNow;
_roomsByConnection[connectionId] = room.Code;
return new EchoJoinResult { DeviceId = device.DeviceId, RoomCode = room.Code, Room = SnapshotLocked(room) };
}
}
public (string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId)
{
if (!_roomsByConnection.TryRemove(connectionId, out var roomCode)) return (null, null);
if (!_rooms.TryGetValue(roomCode, out var room)) return (roomCode, null);
lock (room)
{
room.Devices.RemoveAll(device => device.ConnectionId == connectionId);
room.Round = null;
room.LastActivity = DateTimeOffset.UtcNow;
if (room.Devices.Count == 0) _rooms.TryRemove(roomCode, out _);
return (roomCode, SnapshotLocked(room));
}
}
public EchoRoomSnapshot? Snapshot(string roomCode)
{
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
lock (room) return SnapshotLocked(room);
}
public IReadOnlyCollection<string> MeasurableRooms =>
[.. _rooms.Values.Where(room => room.Devices.Count >= 2).Select(room => room.Code)];
public EchoRoundSchedule? StartRound(
string roomCode,
int slotMilliseconds,
int tailMilliseconds,
TimeSpan lead,
TimeSpan grace)
{
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
lock (room)
{
if (room.Round is not null || room.Devices.Count < 2) return null;
var slotOrder = RotatedSlotOrder(room);
var startsAt = DateTimeOffset.UtcNow + lead;
var duration = TimeSpan.FromMilliseconds(slotOrder.Length * slotMilliseconds + tailMilliseconds);
room.Round = new EchoRound
{
RoundId = Guid.NewGuid().ToString("N")[..12],
SlotOrder = slotOrder,
SlotMilliseconds = slotMilliseconds,
Deadline = startsAt + duration + grace
};
room.LastActivity = DateTimeOffset.UtcNow;
return new EchoRoundSchedule
{
RoundId = room.Round.RoundId,
SlotOrder = slotOrder,
SlotMilliseconds = slotMilliseconds,
TailMilliseconds = tailMilliseconds,
StartsAtUnixMs = startsAt.ToUnixTimeMilliseconds()
};
}
}
public bool Report(string connectionId, EchoPeakReport report)
{
var device = FindDevice(connectionId, out var room);
if (device is null || room is null) return false;
lock (room)
{
if (room.Round?.RoundId != report.RoundId) return false;
if (!room.Round.SlotOrder.Contains(device.DeviceId)) return false;
room.Round.Reports[device.DeviceId] = report;
room.LastActivity = DateTimeOffset.UtcNow;
return true;
}
}
public EchoRoundResult? TryCloseRound(string roomCode)
{
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
lock (room)
{
var round = room.Round;
if (round is null) return null;
var everyoneReported = round.SlotOrder.All(round.Reports.ContainsKey);
if (!everyoneReported && DateTimeOffset.UtcNow < round.Deadline) return null;
room.Round = null;
return new EchoRoundResult
{
RoundId = round.RoundId,
SlotOrder = round.SlotOrder,
Reports = [.. round.SlotOrder.Where(round.Reports.ContainsKey).Select(id => round.Reports[id])]
};
}
}
public int PruneIdle(TimeSpan idleFor)
{
var cutoff = DateTimeOffset.UtcNow - idleFor;
var stale = _rooms.Values.Where(room => room.LastActivity < cutoff).Select(room => room.Code).ToList();
foreach (var code in stale) _rooms.TryRemove(code, out _);
return stale.Count;
}
private static EchoRoomSnapshot SnapshotLocked(Room room) =>
new()
{
RoomCode = room.Code,
Devices =
[
.. room.Devices.Select(device => new EchoDeviceSnapshot
{
DeviceId = device.DeviceId,
DisplayName = device.DisplayName,
SampleRate = device.SampleRate
})
]
};
private static string[] RotatedSlotOrder(Room room)
{
var offset = room.RoundCounter++ % room.Devices.Count;
return [.. room.Devices.Skip(offset).Concat(room.Devices.Take(offset)).Select(device => device.DeviceId)];
}
private EchoDevice? FindDevice(string connectionId, out Room? room)
{
room = null;
if (!_roomsByConnection.TryGetValue(connectionId, out var roomCode)) return null;
if (!_rooms.TryGetValue(roomCode, out room)) return null;
lock (room) return room.Devices.FirstOrDefault(device => device.ConnectionId == connectionId);
}
}
@@ -1,107 +0,0 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Services.Implementations;
public sealed class EchoRoundSettings
{
public const string SectionName = "Echo";
/// <summary>
/// Slot length. Wide enough that no device's chirp can land in another's search window once
/// unknown output latency (tens of milliseconds, different per device) and message jitter are
/// accounted for.
/// </summary>
public int SlotMilliseconds { get; set; } = 500;
/// <summary>Time after the last chirp for propagation, detection and reporting.</summary>
public int TailMilliseconds { get; set; } = 700;
public int GraceMilliseconds { get; set; } = 1500;
public int GapMilliseconds { get; set; } = 250;
public int IdleRoomMinutes { get; set; } = 10;
/// <summary>
/// How far ahead a round is announced. Must exceed the worst message delivery plus client
/// stall, or a device will find its slot already gone and sit the round out.
/// </summary>
public int LeadMilliseconds { get; set; } = 600;
}
/// <summary>
/// Drives continuous ranging: opens a round for every room that has two or more devices, closes it
/// once everyone has reported or the deadline passes, and broadcasts the raw peak table.
/// </summary>
public sealed class EchoRoundService(
IEchoRoomStore rooms,
IHubContext<EchoHub> hub,
EchoRoundSettings settings,
ILogger<EchoRoundService> logger) : BackgroundService
{
private readonly Dictionary<string, DateTimeOffset> _nextRoundAllowedAt = [];
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var lastPrune = DateTimeOffset.UtcNow;
while (!stoppingToken.IsCancellationRequested)
{
try
{
await TickAsync();
lastPrune = PruneIfDue(lastPrune);
}
catch (Exception error)
{
logger.LogError(error, "Echo round tick failed");
}
await Task.Delay(50, stoppingToken);
}
}
private async Task TickAsync()
{
foreach (var roomCode in rooms.MeasurableRooms)
{
await CloseFinishedRoundAsync(roomCode);
await OpenRoundIfDueAsync(roomCode);
}
}
private async Task CloseFinishedRoundAsync(string roomCode)
{
var result = rooms.TryCloseRound(roomCode);
if (result is null) return;
_nextRoundAllowedAt[roomCode] = DateTimeOffset.UtcNow.AddMilliseconds(settings.GapMilliseconds);
await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundComplete", result);
}
private async Task OpenRoundIfDueAsync(string roomCode)
{
if (_nextRoundAllowedAt.TryGetValue(roomCode, out var earliest) && DateTimeOffset.UtcNow < earliest) return;
var schedule = rooms.StartRound(
roomCode,
settings.SlotMilliseconds,
settings.TailMilliseconds,
TimeSpan.FromMilliseconds(settings.LeadMilliseconds),
TimeSpan.FromMilliseconds(settings.GraceMilliseconds));
if (schedule is null) return;
await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundStarting", schedule);
}
private DateTimeOffset PruneIfDue(DateTimeOffset lastPrune)
{
if (DateTimeOffset.UtcNow - lastPrune < TimeSpan.FromMinutes(1)) return lastPrune;
var pruned = rooms.PruneIdle(TimeSpan.FromMinutes(settings.IdleRoomMinutes));
if (pruned > 0) logger.LogInformation("Pruned {Count} idle echo room(s)", pruned);
return DateTimeOffset.UtcNow;
}
}
@@ -100,11 +100,26 @@ public sealed class Stockfish : IChessEngine
if (line == token) break;
}
// If Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
// here forever — that would block GameStore's removal task and leave the finished game
// stuck in the store, cluttering the watch page indefinitely.
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
public async ValueTask DisposeAsync()
{
Send("quit");
_stdin.Close();
await _p.WaitForExitAsync();
try
{
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
}
catch (TimeoutException)
{
if (!_p.HasExited)
_p.Kill(entireProcessTree: true);
}
_p.Dispose();
}
}
@@ -1,45 +0,0 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Process-wide registry of ranging rooms. Holds only device identity and the sample indices each
/// device reported, so the server is a scheduler and a relay — it never sees audio.
/// </summary>
public interface IEchoRoomStore
{
/// <summary>Add a device to a room, creating the room if this is the first arrival.</summary>
EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate);
/// <summary>Remove whichever device owns this connection, returning the room it left.</summary>
(string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId);
/// <summary>Snapshot of a room's roster, or null if the room is gone.</summary>
EchoRoomSnapshot? Snapshot(string roomCode);
/// <summary>Room codes with at least two devices, which is the minimum for a measurement.</summary>
IReadOnlyCollection<string> MeasurableRooms { get; }
/// <summary>
/// Open a new round for a room, rotating which device chirps first so no single device is
/// permanently the slot-order anchor.
/// </summary>
EchoRoundSchedule? StartRound(
string roomCode,
int slotMilliseconds,
int tailMilliseconds,
TimeSpan lead,
TimeSpan grace);
/// <summary>File a device's peaks against the room's open round.</summary>
bool Report(string connectionId, EchoPeakReport report);
/// <summary>
/// Close the open round if every device has reported or its deadline has passed, returning the
/// reports to broadcast.
/// </summary>
EchoRoundResult? TryCloseRound(string roomCode);
/// <summary>Drop rooms that have had no activity for longer than <paramref name="idleFor"/>.</summary>
int PruneIdle(TimeSpan idleFor);
}
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
body {
background: #1a1a1a;
min-height: 100vh;
margin: 0;
padding: 0;
}
.casino-container {
max-width: 1000px;
margin: 0 auto;
padding: 4rem 2rem;
text-align: center;
}
.casino-header {
color: #f5f5f5;
font-size: 2.5rem;
margin-bottom: 0.5rem;
font-weight: 300;
letter-spacing: 0.1em;
}
.casino-subtitle {
color: #a0a0a0;
font-size: 1rem;
margin-bottom: 4rem;
font-weight: 300;
}
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin: 3rem auto;
max-width: 900px;
padding: 0 1rem;
}
.game-card {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 2rem 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
align-items: center;
}
.game-card:hover {
background: #2f2f2f;
border-color: #4a4a4a;
transform: translateY(-2px);
}
.game-card.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.game-card.disabled:hover {
background: #2a2a2a;
border-color: #3a3a3a;
transform: none;
}
.game-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.9;
}
.game-title {
font-size: 1.2rem;
font-weight: 400;
color: #f5f5f5;
margin-bottom: 0.75rem;
letter-spacing: 0.05em;
}
.game-description {
font-size: 0.9rem;
color: #999;
line-height: 1.6;
font-weight: 300;
}
.coming-soon {
color: #666;
font-style: italic;
margin-top: 0.5rem;
font-size: 0.85rem;
}
.home-button {
margin-top: 4rem;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
background: transparent;
color: #f5f5f5;
border: 1px solid #3a3a3a;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
letter-spacing: 0.05em;
}
.home-button:hover {
background: #2a2a2a;
border-color: #4a4a4a;
}
@media (max-width: 768px) {
.casino-header {
font-size: 2rem;
}
.games-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,301 @@
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
body {
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
min-height: 100vh;
margin: 0;
color: #e6e6e6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#splitApp {
max-width: 480px;
margin: 0 auto;
padding: 1.25rem 1rem 3rem;
}
/* ---------- Header ---------- */
#splitHeader h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 300;
letter-spacing: 0.04em;
color: #f5f5f5;
position: relative;
display: inline-block;
padding-bottom: 0.35rem;
}
#splitHeader h1::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 36px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, #d8b46a, #a9782f);
}
.splitSubtitle {
margin: 0.35rem 0 1.25rem;
color: #8f8f8f;
font-size: 0.85rem;
}
/* ---------- The answer ---------- */
#splitResult {
background: linear-gradient(160deg, #1f2a21, #161c17);
border: 1px solid #2c3a2f;
border-radius: 14px;
padding: 1.4rem 1.1rem;
text-align: center;
margin-bottom: 1.5rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
#splitResult.hasAmount {
border-color: #3c6b46;
box-shadow: 0 0 0 1px rgba(78, 201, 122, 0.08), 0 6px 20px rgba(0, 0, 0, 0.35);
}
#splitResultLabel {
color: #8f9d92;
font-size: 0.78rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#splitResultAmount {
margin-top: 0.35rem;
font-size: clamp(2.6rem, 14vw, 3.4rem);
font-weight: 600;
line-height: 1.05;
color: #f2f2f2;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
#splitResult.hasAmount #splitResultAmount {
color: #7ee2a0;
}
#splitOddNote {
margin-top: 0.7rem;
color: #d8b46a;
font-size: 0.85rem;
line-height: 1.45;
}
#splitDustNote {
margin-top: 0.5rem;
color: #e0a1a1;
font-size: 0.8rem;
line-height: 1.4;
}
/* ---------- Inputs ---------- */
.splitField {
display: block;
margin-bottom: 1.35rem;
}
.splitLabel {
display: block;
margin-bottom: 0.5rem;
color: #9a9a9a;
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.potInputWrap {
display: flex;
align-items: center;
gap: 0.4rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 12px;
padding: 0.2rem 0.75rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.potInputWrap:focus-within {
border-color: #d8b46a;
box-shadow: 0 0 0 3px rgba(216, 180, 106, 0.12);
}
.potCurrency {
color: #777;
font-size: 1.6rem;
font-weight: 300;
}
#potInput {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-size: 2rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
padding: 0.55rem 0;
font-family: inherit;
}
#potInput::placeholder {
color: #4a4a4a;
}
.clearPotBtn {
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: #262626;
color: #999;
font-size: 0.85rem;
cursor: pointer;
}
.clearPotBtn:hover {
background: #333;
color: #ddd;
}
/* ---------- Tap targets ---------- */
.chipRow {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.choiceChip {
flex: 1 1 auto;
min-width: 58px;
min-height: 52px;
padding: 0.5rem 0.7rem;
background: linear-gradient(180deg, #2e2e2e, #232323);
border: 1px solid #3a3a3a;
border-radius: 11px;
color: #ddd;
font-size: 1rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.choiceChip.selected {
background: linear-gradient(180deg, #3a2f18, #241d0f);
border-color: #d8b46a;
color: #f0d9a8;
font-weight: 600;
}
.waysStepper {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1 1 auto;
min-width: 150px;
background: #191919;
border: 1px solid #333;
border-radius: 11px;
padding: 0.2rem 0.4rem;
}
.stepBtn {
width: 40px;
height: 44px;
border: none;
border-radius: 8px;
background: #262626;
color: #ddd;
font-size: 1.25rem;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stepBtn:hover {
background: #333;
}
.waysReadout {
flex: 1;
text-align: center;
color: #999;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
/* ---------- Footer actions ---------- */
#splitActions {
display: flex;
gap: 0.6rem;
margin-top: 1.75rem;
}
.splitBtn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0.6rem 0.9rem;
border-radius: 10px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #ddd;
font-size: 0.9rem;
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
}
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.splitBtnGhost {
background: transparent;
}
.splitFootnote {
margin-top: 1.5rem;
color: #6f6f6f;
font-size: 0.78rem;
line-height: 1.5;
text-align: center;
}
.splitFootnote a {
color: #a9782f;
}
/* Roomier once there's a real screen to work with. */
@media (min-width: 700px) {
#splitApp {
padding-top: 2.5rem;
}
#splitHeader h1 {
font-size: 1.8rem;
}
}
-287
View File
@@ -1,287 +0,0 @@
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
margin: 0;
padding: 2rem 1.25rem 4rem;
}
#echoHeader {
max-width: 46rem;
margin: 0 auto 1.5rem;
}
#echoHeader h1 {
color: var(--color-heading);
font-size: 2.4rem;
letter-spacing: -0.02em;
margin: 0 0 0.6rem;
}
.echo-blurb {
line-height: 1.6;
margin: 0 0 0.5rem;
}
.echo-privacy {
color: var(--color-text-subtle);
font-family: var(--font-mono);
font-size: 0.8rem;
margin: 0;
}
#echoControls {
align-items: flex-end;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0 auto 1rem;
max-width: 46rem;
}
#echoControls label {
color: var(--color-text-muted);
display: flex;
flex-direction: column;
font-family: var(--font-mono);
font-size: 0.75rem;
gap: 0.3rem;
text-transform: lowercase;
}
#echoControls input {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-heading);
font-family: var(--font-mono);
font-size: 1rem;
padding: 0.5rem 0.6rem;
width: 7rem;
}
#echoControls input:focus {
border-color: var(--color-accent-border);
outline: none;
}
#echoControls button {
background: transparent;
border: 1px solid var(--color-accent-border);
border-radius: 6px;
color: var(--color-accent);
cursor: pointer;
font-family: var(--font-mono);
font-size: 0.9rem;
padding: 0.6rem 1.1rem;
transition: background 0.15s ease, color 0.15s ease;
}
#echoControls button:hover:not(:disabled) {
background: var(--color-accent);
color: var(--color-bg);
}
#echoControls button:disabled {
border-color: var(--color-border);
color: var(--color-text-subtle);
cursor: default;
}
.echo-status,
.echo-share {
font-family: var(--font-mono);
font-size: 0.8rem;
margin: 0 auto 0.4rem;
max-width: 46rem;
}
.echo-share a {
color: var(--color-accent);
text-decoration: none;
word-break: break-all;
}
#echoWarnings {
margin: 0 auto;
max-width: 46rem;
}
.echo-warning {
background: rgba(255, 170, 0, 0.08);
border: 1px solid rgba(255, 170, 0, 0.35);
border-radius: 6px;
color: #ffcc66;
font-size: 0.85rem;
line-height: 1.5;
margin: 0.5rem 0;
padding: 0.7rem 0.9rem;
}
.echo-readout {
align-items: baseline;
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 2rem auto;
max-width: 46rem;
min-height: 6rem;
}
.echo-metres {
color: var(--color-accent);
font-family: var(--font-mono);
font-size: clamp(3.5rem, 14vw, 7rem);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.echo-metres small {
color: var(--color-text-subtle);
font-size: 0.28em;
margin-left: 0.2em;
}
.echo-readout-detail,
.echo-readout-idle {
color: var(--color-text-subtle);
font-family: var(--font-mono);
font-size: 0.8rem;
}
#echoPanels {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
margin: 0 auto;
max-width: 46rem;
}
.echo-panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem 1.1rem;
}
.echo-panel-wide {
margin: 1rem auto 0;
max-width: 46rem;
}
.echo-panel h2 {
color: var(--color-heading-secondary);
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.08em;
margin: 0 0 0.75rem;
text-transform: uppercase;
}
.echo-hint {
color: var(--color-text-subtle);
font-size: 0.8rem;
margin: -0.4rem 0 0.75rem;
}
#echoRoster {
list-style: none;
margin: 0;
padding: 0;
}
.echo-device {
border-bottom: 1px solid var(--color-border);
font-family: var(--font-mono);
font-size: 0.85rem;
padding: 0.45rem 0;
}
.echo-device:last-child {
border-bottom: none;
}
.echo-device-self {
color: var(--color-accent);
}
#echoPairs {
overflow-x: auto;
}
#echoPairs table {
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 0.85rem;
width: 100%;
}
#echoPairs th {
color: var(--color-text-subtle);
font-weight: 500;
padding: 0 0.6rem 0.4rem 0;
text-align: left;
}
#echoPairs td {
border-top: 1px solid var(--color-border);
color: var(--color-heading-secondary);
font-variant-numeric: tabular-nums;
padding: 0.4rem 0.6rem 0.4rem 0;
}
.echo-dropped td {
color: var(--color-text-subtle);
text-decoration: line-through;
}
.echo-slots {
list-style: none;
margin: 0 0 0.6rem;
padding: 0;
}
.echo-slots li {
color: var(--color-heading-secondary);
font-family: var(--font-mono);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
padding: 0.2rem 0;
}
.echo-slots .echo-missed {
color: #ff7676;
}
.echo-diag-line {
color: var(--color-text-subtle);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
margin: 0;
}
.echo-diag-line strong {
color: #ff7676;
}
#echoTrace {
display: block;
height: 160px;
width: 100%;
}
.echo-back {
color: var(--color-text-subtle);
display: block;
font-family: var(--font-mono);
font-size: 0.8rem;
margin: 2rem auto 0;
max-width: 46rem;
text-decoration: none;
}
.echo-back:hover {
color: var(--color-accent);
}
@@ -1,160 +0,0 @@
/*
* Microphone capture and chirp playback, both indexed by AudioContext frame.
*
* Frames, not wall-clock: currentTime * sampleRate is exactly the frame number, so a playback
* scheduled at an audio-clock time converts directly into a position in the recording. Nothing here
* depends on a timer firing on schedule, which matters because a hidden tab's timers are throttled
* to about once a second and its chirp would land in another device's slot.
*/
const EchoAudio = {
TARGET_SAMPLE_RATE: 48000,
RING_SECONDS: 20,
BLUETOOTH_HINTS: /bluetooth|airpod|hands-?free|headset|\bbt\b|wireless/i,
context: null,
stream: null,
capture: null,
ring: null,
highestFrame: 0,
peakAmplitude: 0,
warnings: [],
onSamples: null,
async start() {
if (this.context) return this.describe();
this.stream = await navigator.mediaDevices.getUserMedia({
// Echo cancellation exists to delete sounds this device just played, which is exactly
// the measurement. Gain control and noise suppression distort the chirp's envelope.
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
channelCount: 1
}
});
this.context = new AudioContext({ sampleRate: this.TARGET_SAMPLE_RATE, latencyHint: "interactive" });
await this.context.audioWorklet.addModule("/js/EchoScripts/EchoCaptureProcessor.js");
await this.context.resume();
this.ring = new Float32Array(Math.ceil(this.context.sampleRate * this.RING_SECONDS));
this.attachCapture();
this.warnings = this.inspectDevice();
return this.describe();
},
attachCapture() {
const source = this.context.createMediaStreamSource(this.stream);
this.capture = new AudioWorkletNode(this.context, "echo-capture", { numberOfOutputs: 0 });
this.capture.port.onmessage = ({ data }) => this.write(data.frame, data.samples);
source.connect(this.capture);
return this.capture;
},
write(frame, samples) {
const capacity = this.ring.length;
let loudest = 0;
for (let i = 0; i < samples.length; i++) {
this.ring[(frame + i) % capacity] = samples[i];
loudest = Math.max(loudest, Math.abs(samples[i]));
}
this.peakAmplitude = Math.max(this.peakAmplitude * 0.95, loudest);
this.highestFrame = Math.max(this.highestFrame, frame + samples.length);
this.onSamples?.(this.highestFrame);
return this.highestFrame;
},
/** The recording position for an AudioContext time. Exact: currentTime * sampleRate is a frame. */
frameAt(contextTime) {
return Math.round(contextTime * this.context.sampleRate);
},
/** Copy an absolute frame range out of the ring, or null if it is not (or no longer) held. */
read(startFrame, length) {
if (length <= 0) return null;
if (startFrame + length > this.highestFrame) return null;
if (startFrame < this.highestFrame - this.ring.length) return null;
const capacity = this.ring.length;
const window = new Float32Array(length);
for (let i = 0; i < length; i++) window[i] = this.ring[((startFrame + i) % capacity + capacity) % capacity];
return window;
},
/**
* Schedule a chirp on the audio clock and return the frame it was scheduled for. When it
* actually leaves the speaker is later by an unknown output latency and does not need to be
* known — it is recovered from the recording of it.
*/
play(chirp, atContextTime, gain = 0.5) {
const when = Math.max(atContextTime, this.context.currentTime);
const buffer = this.context.createBuffer(1, chirp.length, this.context.sampleRate);
buffer.copyToChannel(chirp, 0);
const source = this.context.createBufferSource();
const volume = this.context.createGain();
volume.gain.value = gain;
source.buffer = buffer;
source.connect(volume).connect(this.context.destination);
source.start(when);
return this.frameAt(when);
},
/** How far behind the audio clock the main thread's view of the recording is running. */
captureLagSeconds() {
return this.context.currentTime - this.highestFrame / this.context.sampleRate;
},
/** Reported output latency, which bounds how late a scheduled chirp can reach the microphone. */
outputLatencySeconds() {
return (this.context.outputLatency || 0) + (this.context.baseLatency || 0);
},
inspectDevice() {
const warnings = [];
const track = this.stream.getAudioTracks()[0];
const settings = track?.getSettings() ?? {};
if (this.context.sampleRate !== this.TARGET_SAMPLE_RATE)
warnings.push(`Running at ${this.context.sampleRate}Hz instead of 48000Hz — ranges stay correct but resolution drops.`);
if (this.BLUETOOTH_HINTS.test(track?.label ?? ""))
warnings.push("This looks like a Bluetooth microphone. Bluetooth resamples and re-times the stream, which breaks the measurement — switch to the built-in speaker and microphone.");
for (const [name, label] of [["echoCancellation", "Echo cancellation"], ["autoGainControl", "Auto gain"], ["noiseSuppression", "Noise suppression"]])
if (settings[name] === true) warnings.push(`${label} could not be turned off on this device — the measurement will be unreliable.`);
return warnings;
},
describe() {
const track = this.stream.getAudioTracks()[0];
return {
sampleRate: this.context.sampleRate,
label: track?.label ?? "microphone",
outputLatencyMs: Math.round(this.outputLatencySeconds() * 1000),
warnings: this.warnings
};
},
async stop() {
this.stream?.getTracks().forEach(track => track.stop());
await this.context?.close();
this.context = null;
this.stream = null;
this.capture = null;
this.ring = null;
this.highestFrame = 0;
this.onSamples = null;
return true;
}
};
if (typeof window !== "undefined") window.EchoAudio = EchoAudio;
@@ -1,39 +0,0 @@
/*
* Capture worklet. Every block is tagged with the AudioContext frame it started at, which makes the
* context's own clock the index for all captured audio: a scheduled playback time converts to a
* recording position exactly, with no dependence on when the main thread got round to noticing.
*
* A worklet rather than ScriptProcessorNode because a dropped buffer would break the continuity
* that the sample count depends on.
*/
const BLOCK_SAMPLES = 2048;
class EchoCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.block = new Float32Array(BLOCK_SAMPLES);
this.filled = 0;
this.blockStartFrame = 0;
}
process(inputs) {
const channel = inputs[0]?.[0];
if (!channel) return true;
for (let i = 0; i < channel.length; i++) {
if (this.filled === 0) this.blockStartFrame = currentFrame + i;
this.block[this.filled++] = channel[i];
if (this.filled === BLOCK_SAMPLES) this.flush();
}
return true;
}
flush() {
const samples = this.block.slice(0, this.filled);
this.port.postMessage({ frame: this.blockStartFrame, samples }, [samples.buffer]);
this.filled = 0;
}
}
registerProcessor("echo-capture", EchoCaptureProcessor);
@@ -1,585 +0,0 @@
/*
* Pure signal-processing and geometry for acoustic ranging. No DOM, no Web Audio, no network:
* everything here is a function of its arguments so the simulator and the tests can drive the
* exact code the page runs.
*/
const EchoDsp = {
speedOfSound(temperatureCelsius = 20) {
return 331.3 + 0.606 * temperatureCelsius;
},
nextPowerOfTwo(value) {
let size = 1;
while (size < value) size <<= 1;
return size;
},
/**
* Linear frequency sweep, tapered at both ends. A sweep is used rather than a tone because a
* tone's autocorrelation peaks once per period, leaving no unambiguous arrival to measure.
*/
makeChirp({ sampleRate, durationSeconds, startHz, endHz, taperFraction = 0.15 }) {
const length = Math.round(sampleRate * durationSeconds);
const sweepRate = (endHz - startHz) / durationSeconds;
const chirp = new Float32Array(length);
for (let i = 0; i < length; i++) {
const t = i / sampleRate;
chirp[i] = Math.sin(2 * Math.PI * (startHz * t + 0.5 * sweepRate * t * t));
}
return this.applyTaper(chirp, taperFraction);
},
applyTaper(signal, fraction) {
const edge = Math.max(1, Math.floor(signal.length * fraction));
for (let i = 0; i < edge; i++) {
const window = 0.5 - 0.5 * Math.cos((Math.PI * i) / edge);
signal[i] *= window;
signal[signal.length - 1 - i] *= window;
}
return signal;
},
twiddles(size) {
this._twiddleCache ??= new Map();
const cached = this._twiddleCache.get(size);
if (cached) return cached;
const half = size >> 1;
const table = { cos: new Float64Array(half), sin: new Float64Array(half) };
for (let i = 0; i < half; i++) {
const angle = (-2 * Math.PI * i) / size;
table.cos[i] = Math.cos(angle);
table.sin[i] = Math.sin(angle);
}
this._twiddleCache.set(size, table);
return table;
},
fft(real, imaginary, inverse = false) {
const size = real.length;
this.reverseBits(real, imaginary);
const { cos, sin } = this.twiddles(size);
for (let span = 2; span <= size; span <<= 1) {
const half = span >> 1;
const stride = size / span;
for (let base = 0; base < size; base += span) {
for (let k = 0; k < half; k++) {
const twiddle = k * stride;
const wReal = cos[twiddle];
const wImaginary = inverse ? -sin[twiddle] : sin[twiddle];
const lower = base + k;
const upper = lower + half;
const productReal = real[upper] * wReal - imaginary[upper] * wImaginary;
const productImaginary = real[upper] * wImaginary + imaginary[upper] * wReal;
real[upper] = real[lower] - productReal;
imaginary[upper] = imaginary[lower] - productImaginary;
real[lower] += productReal;
imaginary[lower] += productImaginary;
}
}
}
if (!inverse) return { real, imaginary };
for (let i = 0; i < size; i++) {
real[i] /= size;
imaginary[i] /= size;
}
return { real, imaginary };
},
reverseBits(real, imaginary) {
const size = real.length;
for (let i = 1, j = 0; i < size; i++) {
let bit = size >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i >= j) continue;
[real[i], real[j]] = [real[j], real[i]];
[imaginary[i], imaginary[j]] = [imaginary[j], imaginary[i]];
}
return { real, imaginary };
},
/**
* Matched-filter envelope of a recording against a template, via FFT cross-correlation.
* Negative frequencies are dropped so the result is the analytic envelope rather than a burst
* oscillating at the sweep frequency — an oscillating peak defeats sub-sample interpolation
* and makes first-arrival detection jitter by half a carrier period.
*/
matchedFilterEnvelope(recording, template) {
const size = this.nextPowerOfTwo(recording.length + template.length);
const recordingReal = new Float64Array(size);
const recordingImaginary = new Float64Array(size);
const templateReal = new Float64Array(size);
const templateImaginary = new Float64Array(size);
recordingReal.set(recording);
templateReal.set(template);
this.fft(recordingReal, recordingImaginary);
this.fft(templateReal, templateImaginary);
const analyticReal = new Float64Array(size);
const analyticImaginary = new Float64Array(size);
const half = size >> 1;
for (let i = 0; i <= half; i++) {
const gain = i === 0 || i === half ? 1 : 2;
analyticReal[i] = gain * (recordingReal[i] * templateReal[i] + recordingImaginary[i] * templateImaginary[i]);
analyticImaginary[i] = gain * (recordingImaginary[i] * templateReal[i] - recordingReal[i] * templateImaginary[i]);
}
this.fft(analyticReal, analyticImaginary, true);
const envelope = new Float32Array(recording.length);
for (let i = 0; i < envelope.length; i++)
envelope[i] = Math.sqrt(analyticReal[i] * analyticReal[i] + analyticImaginary[i] * analyticImaginary[i]);
return envelope;
},
maxInRange(values, start, end) {
let index = start;
let value = -Infinity;
for (let i = start; i < end; i++) {
if (values[i] <= value) continue;
value = values[i];
index = i;
}
return { index, value };
},
medianInRange(values, start, end, sampleLimit = 2048) {
const span = end - start;
if (span <= 0) return 0;
const stride = Math.max(1, Math.floor(span / sampleLimit));
const sampled = [];
for (let i = start; i < end; i += stride) sampled.push(values[i]);
sampled.sort((left, right) => left - right);
return sampled[sampled.length >> 1];
},
/**
* Sub-sample peak position by fitting a parabola through the peak and its neighbours. One
* tenth of a sample is 0.7mm of range at 48kHz, so this is most of the accuracy for free.
*/
refinePeakIndex(envelope, index) {
if (index <= 0 || index >= envelope.length - 1) return index;
const before = envelope[index - 1];
const peak = envelope[index];
const after = envelope[index + 1];
const curvature = before - 2 * peak + after;
if (curvature === 0) return index;
const offset = (0.5 * (before - after)) / curvature;
return index + Math.max(-1, Math.min(1, offset));
},
/**
* First arrival in a window, not the loudest one. A reflection off a wall or table often
* comes back louder than the direct path, and only the direct path is the distance.
*/
findFirstPeak(envelope, options = {}) {
const start = Math.max(0, Math.floor(options.start ?? 0));
const end = Math.min(envelope.length, Math.ceil(options.end ?? envelope.length));
if (end - start < 8) return null;
const loudest = this.maxInRange(envelope, start, end);
const noiseFloor = this.medianInRange(envelope, start, end);
const snr = noiseFloor > 0 ? loudest.value / noiseFloor : Infinity;
if (snr < (options.minSnr ?? 4)) return null;
// Held above the noise floor so sidelobes cannot trigger it, but well below the loudest
// arrival so that a reflection several times louder than the direct path cannot mask it.
const threshold = Math.max(
noiseFloor * (options.noiseMultiple ?? 6),
loudest.value * (options.relativeThreshold ?? 0.15)
);
let crossing = start;
while (crossing < end && envelope[crossing] < threshold) crossing++;
if (crossing >= end) return null;
const lobeEnd = Math.min(end, crossing + (options.lobeSamples ?? 64));
const arrival = this.maxInRange(envelope, crossing, lobeEnd);
return { index: this.refinePeakIndex(envelope, arrival.index), amplitude: arrival.value, snr };
},
/**
* Locate every chirp of one round in a single device's recording.
*
* The device's own chirp is the anchor: it is always present and always the loudest thing in
* the recording, and its position absorbs this device's own output and input latency. Every
* other slot is then searched relative to that anchor, so no clock agreement between devices
* is required — only that the chirps stay in their slots.
*/
detectSlotPeaks({
envelope,
slotCount,
slotSamples,
ownSlot,
ownSearchStart,
ownSearchSamples,
slotHints = null,
peakOptions = {}
}) {
const own = this.findFirstPeak(envelope, {
...peakOptions,
start: ownSearchStart,
end: ownSearchStart + ownSearchSamples
});
if (!own) return null;
const anchor = own.index - ownSlot * slotSamples;
const pad = Math.floor(slotSamples * 0.45);
const peaks = new Array(slotCount).fill(null);
peaks[ownSlot] = own;
for (let slot = 0; slot < slotCount; slot++) {
if (slot === ownSlot) continue;
const centre = anchor + slot * slotSamples + (slotHints?.[slot] ?? 0);
peaks[slot] = this.findFirstPeak(envelope, {
...peakOptions,
start: centre - pad,
end: centre + pad
});
}
return { anchor, peaks };
},
/**
* Distance between two devices from four arrival indices, each measured inside the recording
* of the device that made it. Clock offset and audio-pipeline latency appear once with each
* sign and cancel; the devices' own speaker-to-microphone spacing does not, and is added back.
*/
pairDistance({ a1, a2, b1, b2, sampleRate, sampleRateA, sampleRateB, speedOfSound, epsilonA = 0, epsilonB = 0 }) {
// Each interval is converted to seconds in its own device's sample rate before the two are
// subtracted: a device that hands back 44100 instead of 48000 would otherwise contribute
// its interval in the wrong unit.
const intervalA = (a2 - a1) / (sampleRateA ?? sampleRate);
const intervalB = (b2 - b1) / (sampleRateB ?? sampleRate);
return ((intervalA - intervalB) / 2) * speedOfSound + (epsilonA + epsilonB) / 2;
},
/**
* Symmetric distance matrix from one round of reports. Entries stay null where either device
* failed to hear one of the four chirps the pair needs.
*/
buildDistanceMatrix(reports, { speedOfSound = 343, maxDistance = 40 } = {}) {
const count = reports.length;
const matrix = Array.from({ length: count }, () => new Array(count).fill(null));
for (let i = 0; i < count; i++) {
matrix[i][i] = 0;
for (let j = i + 1; j < count; j++) {
const distance = this.distanceBetween(reports[i], reports[j], speedOfSound);
if (distance === null || distance < -1 || distance > maxDistance) continue;
matrix[i][j] = Math.max(0, distance);
matrix[j][i] = matrix[i][j];
}
}
return matrix;
},
distanceBetween(deviceA, deviceB, speedOfSound) {
const a1 = deviceA.peaks[deviceA.slot];
const a2 = deviceA.peaks[deviceB.slot];
const b1 = deviceB.peaks[deviceA.slot];
const b2 = deviceB.peaks[deviceB.slot];
if (a1 === null || a2 === null || b1 === null || b2 === null) return null;
return this.pairDistance({
a1,
a2,
b1,
b2,
sampleRateA: deviceA.sampleRate,
sampleRateB: deviceB.sampleRate,
speedOfSound,
epsilonA: deviceA.epsilon ?? 0,
epsilonB: deviceB.epsilon ?? 0
});
},
/**
* Largest set of devices linked by measured distances. Anything outside it cannot be placed
* relative to the others, and leaving it in makes the completed matrix infinite.
*/
largestConnectedComponent(matrix) {
const unvisited = new Set(matrix.map((_, index) => index));
let largest = [];
while (unvisited.size > 0) {
const component = [];
const queue = [unvisited.values().next().value];
unvisited.delete(queue[0]);
while (queue.length > 0) {
const current = queue.pop();
component.push(current);
for (const next of unvisited)
if (matrix[current][next] !== null) {
unvisited.delete(next);
queue.push(next);
}
}
if (component.length > largest.length) largest = component;
}
return largest.sort((left, right) => left - right);
},
/**
* Drop devices whose distances are geometrically impossible. One device reporting a bad peak
* distorts the whole layout, so the worst triangle-inequality offender is removed and the
* check repeated. Below four devices there is no redundancy left and nothing can be checked.
*/
rejectOutliers(matrix, candidates, tolerance = 0.5) {
const keep = [...candidates];
while (keep.length > 3) {
const violations = this.countTriangleViolations(matrix, keep, tolerance);
const worst = violations.reduce((best, count, index) => (count > violations[best] ? index : best), 0);
if (violations[worst] === 0) break;
keep.splice(worst, 1);
}
return keep;
},
submatrix(matrix, indices) {
return indices.map(row => indices.map(column => matrix[row][column]));
},
countTriangleViolations(matrix, keep, tolerance) {
const violations = new Array(keep.length).fill(0);
for (let i = 0; i < keep.length; i++) {
for (let j = i + 1; j < keep.length; j++) {
for (let k = j + 1; k < keep.length; k++) {
const sides = [matrix[keep[i]][keep[j]], matrix[keep[j]][keep[k]], matrix[keep[i]][keep[k]]];
if (sides.some(side => side === null)) continue;
const longest = Math.max(...sides);
const perimeter = sides.reduce((sum, side) => sum + side, 0);
if (longest <= perimeter - longest + tolerance) continue;
violations[i]++;
violations[j]++;
violations[k]++;
}
}
}
return violations;
},
/** Fill gaps with the shortest known path between the two devices so MDS gets a full matrix. */
completeMatrix(matrix) {
const count = matrix.length;
const filled = matrix.map(row => row.map(value => (value === null ? Infinity : value)));
for (let via = 0; via < count; via++)
for (let i = 0; i < count; i++)
for (let j = 0; j < count; j++)
filled[i][j] = Math.min(filled[i][j], filled[i][via] + filled[via][j]);
return filled;
},
/** Jacobi eigendecomposition of a symmetric matrix. Returns eigenvalues and column vectors. */
symmetricEigen(matrix, maxSweeps = 100, tolerance = 1e-14) {
const count = matrix.length;
const working = matrix.map(row => Float64Array.from(row));
const vectors = Array.from({ length: count }, (_, i) => {
const column = new Float64Array(count);
column[i] = 1;
return column;
});
for (let sweep = 0; sweep < maxSweeps; sweep++) {
if (this.offDiagonalMagnitude(working) < tolerance) break;
for (let p = 0; p < count - 1; p++)
for (let q = p + 1; q < count; q++)
this.rotateOut(working, vectors, p, q);
}
return {
values: working.map((row, i) => row[i]),
vectors
};
},
offDiagonalMagnitude(matrix) {
let total = 0;
for (let i = 0; i < matrix.length; i++)
for (let j = i + 1; j < matrix.length; j++) total += matrix[i][j] * matrix[i][j];
return total;
},
rotateOut(matrix, vectors, p, q) {
if (Math.abs(matrix[p][q]) < 1e-300) return matrix;
const theta = (matrix[q][q] - matrix[p][p]) / (2 * matrix[p][q]);
const sign = theta >= 0 ? 1 : -1;
const tangent = sign / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
const cosine = 1 / Math.sqrt(tangent * tangent + 1);
const sine = tangent * cosine;
const count = matrix.length;
for (let k = 0; k < count; k++) {
const left = matrix[k][p];
const right = matrix[k][q];
matrix[k][p] = cosine * left - sine * right;
matrix[k][q] = sine * left + cosine * right;
}
for (let k = 0; k < count; k++) {
const left = matrix[p][k];
const right = matrix[q][k];
matrix[p][k] = cosine * left - sine * right;
matrix[q][k] = sine * left + cosine * right;
}
for (let k = 0; k < count; k++) {
const left = vectors[k][p];
const right = vectors[k][q];
vectors[k][p] = cosine * left - sine * right;
vectors[k][q] = sine * left + cosine * right;
}
return matrix;
},
/**
* Classical multidimensional scaling: coordinates whose pairwise distances best reproduce the
* matrix. The result is only defined up to rotation, translation and mirroring.
*/
classicalMds(distances, dimensions = 2) {
const count = distances.length;
const squared = distances.map(row => row.map(value => value * value));
const rowMeans = squared.map(row => row.reduce((sum, value) => sum + value, 0) / count);
const grandMean = rowMeans.reduce((sum, value) => sum + value, 0) / count;
const centred = squared.map((row, i) => row.map((value, j) => -0.5 * (value - rowMeans[i] - rowMeans[j] + grandMean)));
const { values, vectors } = this.symmetricEigen(centred);
const order = values
.map((value, index) => ({ value, index }))
.sort((left, right) => right.value - left.value)
.slice(0, dimensions);
return Array.from({ length: count }, (_, i) =>
order.map(({ value, index }) => vectors[i][index] * Math.sqrt(Math.max(0, value)))
);
},
/**
* Rotate, mirror and translate a constellation onto a reference layout. Without this, every
* solve returns an arbitrary orientation and the display spins and flips between updates.
*/
alignToReference(points, reference) {
if (!reference || reference.length !== points.length || points.length === 0) return points;
const pointCentre = this.centroid(points);
const referenceCentre = this.centroid(reference);
let best = null;
for (const mirror of [1, -1]) {
const candidate = this.rotateOnto(points, reference, pointCentre, referenceCentre, mirror);
if (!best || candidate.residual < best.residual) best = candidate;
}
return best.points;
},
centroid(points) {
const sum = points.reduce((total, [x, y]) => [total[0] + x, total[1] + y], [0, 0]);
return [sum[0] / points.length, sum[1] / points.length];
},
rotateOnto(points, reference, pointCentre, referenceCentre, mirror) {
let sineTerm = 0;
let cosineTerm = 0;
for (let i = 0; i < points.length; i++) {
const px = (points[i][0] - pointCentre[0]) * mirror;
const py = points[i][1] - pointCentre[1];
const qx = reference[i][0] - referenceCentre[0];
const qy = reference[i][1] - referenceCentre[1];
sineTerm += px * qy - py * qx;
cosineTerm += px * qx + py * qy;
}
const angle = Math.atan2(sineTerm, cosineTerm);
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
let residual = 0;
const aligned = points.map((point, i) => {
const px = (point[0] - pointCentre[0]) * mirror;
const py = point[1] - pointCentre[1];
const x = px * cosine - py * sine + referenceCentre[0];
const y = px * sine + py * cosine + referenceCentre[1];
residual += (x - reference[i][0]) ** 2 + (y - reference[i][1]) ** 2;
return [x, y];
});
return { points: aligned, residual };
},
/**
* Full solve for one round: distances, connectivity, outlier rejection, then a constellation
* aligned onto the previous frame.
*
* <c>previousPoints</c> is indexed by report position, with null for devices that were dropped
* last round, so alignment survives devices coming and going.
*/
solveRound(reports, { speedOfSound = 343, previousPoints = null, tolerance = 0.5 } = {}) {
const matrix = this.buildDistanceMatrix(reports, { speedOfSound });
const connected = this.largestConnectedComponent(matrix);
const keep = this.rejectOutliers(matrix, connected, tolerance);
const points = keep.length >= 2 ? this.classicalMds(this.completeMatrix(this.submatrix(matrix, keep))) : [];
const reference = previousPoints ? keep.map(index => previousPoints[index]) : null;
const alignable = reference?.length === points.length && reference.every(Boolean);
return {
matrix,
keep,
points: alignable ? this.alignToReference(points, reference) : points
};
}
};
if (typeof window !== "undefined") window.EchoDsp = EchoDsp;
@@ -1,269 +0,0 @@
/*
* Round lifecycle: join a room, chirp when told to, measure every chirp in our own recording, and
* report the frame indices. Peaks are reported relative to the analysis window rather than to an
* absolute frame — every measurement is a difference within one device's own recording, so a
* constant per-device origin cancels.
*
* Both the chirp and the analysis are driven by the audio clock rather than by timers: a hidden tab
* has its timers throttled to roughly once a second, which is long enough to put its chirp in
* another device's slot and turn the whole round into nonsense.
*/
const EchoSession = {
LEAD_IN_SECONDS: 0.25,
TEMPERATURE_CELSIUS: 20,
MAX_OUTPUT_LATENCY_SECONDS: 0.35,
CHIRP: { durationSeconds: 0.05, startHz: 2000, endHz: 8000 },
CLOCK_SAMPLES: 5,
LATE_TOLERANCE_SECONDS: 0.05,
connection: null,
deviceId: null,
roomCode: null,
serverOffsetMs: 0,
skippedRounds: 0,
devices: [],
chirp: null,
pending: null,
lastRound: null,
hintsByDevice: {},
previousPoints: null,
epsilon: 0.08,
onUpdate: () => {},
async join(roomCode, displayName, { onUpdate }) {
this.onUpdate = onUpdate ?? this.onUpdate;
const audio = await EchoAudio.start();
this.chirp = EchoDsp.makeChirp({ sampleRate: audio.sampleRate, ...this.CHIRP });
EchoAudio.onSamples = frame => this.analyseIfCaptured(frame);
this.connection = new signalR.HubConnectionBuilder().withUrl("/echoHub").withAutomaticReconnect().build();
this.connection.on("RoomChanged", room => this.handleRoomChanged(room));
this.connection.on("RoundStarting", schedule => this.handleRoundStarting(schedule));
this.connection.on("RoundComplete", result => this.handleRoundComplete(result));
await this.connection.start();
const joined = await this.connection.invoke("JoinRoom", roomCode, displayName, audio.sampleRate);
this.deviceId = joined.deviceId;
this.roomCode = joined.roomCode;
this.devices = joined.room.devices;
this.serverOffsetMs = await this.estimateServerOffset();
return { ...joined, audio, serverOffsetMs: this.serverOffsetMs };
},
/**
* Offset from the server clock, by the usual round-trip midpoint. Only needs to be good enough
* to identify which chirp belongs to which slot, which is tens of milliseconds — the ranging
* itself never uses a shared clock.
*/
async estimateServerOffset() {
const offsets = [];
for (let attempt = 0; attempt < this.CLOCK_SAMPLES; attempt++) {
const sent = Date.now();
const serverNow = await this.connection.invoke("ServerTime");
const received = Date.now();
offsets.push(serverNow - (sent + received) / 2);
}
offsets.sort((left, right) => left - right);
return offsets[offsets.length >> 1];
},
serverNowMs() {
return Date.now() + this.serverOffsetMs;
},
handleRoomChanged(room) {
this.devices = room.devices;
this.previousPoints = null;
this.hintsByDevice = {};
this.onUpdate({ kind: "room", room });
return room;
},
/**
* Schedule our chirp for our slot on the audio clock, and record which frames this round will
* occupy so the analysis can start the moment that audio has actually been captured.
*/
handleRoundStarting(schedule) {
const ownSlot = schedule.slotOrder.indexOf(this.deviceId);
if (ownSlot < 0 || !EchoAudio.context) return null;
const sampleRate = EchoAudio.context.sampleRate;
const slotSeconds = schedule.slotMilliseconds / 1000;
const secondsUntilStart = (schedule.startsAtUnixMs - this.serverNowMs()) / 1000;
const roundStartTime = EchoAudio.context.currentTime + secondsUntilStart;
const playAtTime = roundStartTime + ownSlot * slotSeconds;
// Sitting a round out is far better than chirping late: a chirp in the wrong slot is
// attributed to the wrong device and ruins the round for everyone, whereas a missing chirp
// costs only this pair, this round.
if (playAtTime < EchoAudio.context.currentTime - this.LATE_TOLERANCE_SECONDS) {
this.skippedRounds++;
this.onUpdate({ kind: "skipped", schedule, lateBySeconds: EchoAudio.context.currentTime - playAtTime });
return null;
}
const scheduledFrame = EchoAudio.play(this.chirp, playAtTime);
const round = {
schedule,
ownSlot,
sampleRate,
scheduledFrame,
slotSamples: Math.round(slotSeconds * sampleRate),
leadInSamples: Math.round(this.LEAD_IN_SECONDS * sampleRate),
startFrame: EchoAudio.frameAt(roundStartTime),
outputLatencyMs: Math.round(EchoAudio.outputLatencySeconds() * 1000),
captureLagMs: Math.round(EchoAudio.captureLagSeconds() * 1000)
};
round.windowStart = round.startFrame - round.leadInSamples;
round.windowLength =
round.leadInSamples +
schedule.slotOrder.length * round.slotSamples +
Math.round((schedule.tailMilliseconds / 1000) * sampleRate);
this.pending = round;
this.onUpdate({ kind: "roundStarting", round });
return round;
},
/**
* Run as soon as the round's audio exists in the ring. Driven by captured audio rather than a
* timer so a throttled tab still analyses on time.
*/
analyseIfCaptured(highestFrame) {
const round = this.pending;
if (!round) return null;
if (highestFrame < round.windowStart + round.windowLength) return null;
this.pending = null;
return this.analyse(round);
},
analyse(round) {
this.lastRound = round;
const recording = EchoAudio.read(round.windowStart, round.windowLength);
if (!recording) return null;
const envelope = EchoDsp.matchedFilterEnvelope(recording, this.chirp);
const detected = EchoDsp.detectSlotPeaks({
envelope,
slotCount: round.schedule.slotOrder.length,
slotSamples: round.slotSamples,
ownSlot: round.ownSlot,
// Anchored on the frame the chirp was scheduled for, so the only unknown left is how
// long the speaker takes to actually emit it.
ownSearchStart: round.scheduledFrame - round.windowStart,
ownSearchSamples: Math.round((this.MAX_OUTPUT_LATENCY_SECONDS + this.CHIRP.durationSeconds) * round.sampleRate),
slotHints: this.hintsFor(round.schedule)
});
const diagnostics = this.describeRound(round, detected);
this.onUpdate({ kind: "envelope", envelope, detected, round, diagnostics });
if (!detected) return null;
this.rememberHints(round.schedule, detected, round.slotSamples);
return this.report(round, detected);
},
describeRound(round, detected) {
const millisecondsPer = 1000 / round.sampleRate;
return {
captureLagMs: round.captureLagMs,
outputLatencyMs: round.outputLatencyMs,
serverOffsetMs: Math.round(this.serverOffsetMs),
skippedRounds: this.skippedRounds,
clipping: EchoAudio.peakAmplitude >= 0.99,
inputPeak: Number(EchoAudio.peakAmplitude.toFixed(3)),
ownFound: Boolean(detected?.peaks[round.ownSlot]),
slots: round.schedule.slotOrder.map((deviceId, slot) => ({
deviceId,
own: slot === round.ownSlot,
residualMs: detected?.peaks[slot]
? Math.round((detected.peaks[slot].index - (detected.anchor + slot * round.slotSamples)) * millisecondsPer)
: null,
snr: detected?.peaks[slot] ? Math.round(detected.peaks[slot].snr) : null
}))
};
},
report(round, detected) {
return this.connection.invoke("ReportRound", {
deviceId: this.deviceId,
roundId: round.schedule.roundId,
slot: round.ownSlot,
sampleRate: round.sampleRate,
epsilon: this.epsilon,
peaks: detected.peaks.map(peak => peak?.index ?? null)
});
},
/**
* Where each device's chirp actually landed last round, relative to where the slot said it
* would. Output latency differs by tens of milliseconds per device and is stable, so carrying
* the residual forward keeps the search windows centred instead of merely wide.
*/
rememberHints(schedule, detected, slotSamples) {
schedule.slotOrder.forEach((deviceId, slot) => {
const peak = detected.peaks[slot];
if (!peak) return;
this.hintsByDevice[deviceId] = peak.index - (detected.anchor + slot * slotSamples);
});
return this.hintsByDevice;
},
hintsFor(schedule) {
return schedule.slotOrder.map(deviceId => this.hintsByDevice[deviceId] ?? 0);
},
handleRoundComplete(result) {
const reports = result.reports.map(report => ({ ...report, peaks: report.peaks ?? [] }));
const solved = EchoDsp.solveRound(reports, {
speedOfSound: EchoDsp.speedOfSound(this.TEMPERATURE_CELSIUS),
previousPoints: this.previousPoints
});
this.previousPoints = this.pointsByReportIndex(reports, solved);
this.onUpdate({ kind: "solved", result, reports, solved, deviceId: this.deviceId });
return solved;
},
pointsByReportIndex(reports, solved) {
const points = new Array(reports.length).fill(null);
solved.keep.forEach((reportIndex, i) => {
points[reportIndex] = solved.points[i];
});
return points;
},
setEpsilon(metres) {
this.epsilon = metres;
localStorage.setItem("echo.epsilon", String(metres));
return this.epsilon;
},
loadEpsilon() {
const stored = Number(localStorage.getItem("echo.epsilon"));
this.epsilon = Number.isFinite(stored) && stored > 0 ? stored : 0.08;
return this.epsilon;
},
async leave() {
await this.connection?.invoke("LeaveRoom").catch(() => {});
await this.connection?.stop();
await EchoAudio.stop();
this.connection = null;
this.pending = null;
this.previousPoints = null;
this.hintsByDevice = {};
return true;
}
};
if (typeof window !== "undefined") window.EchoSession = EchoSession;
@@ -1,213 +0,0 @@
/*
* Virtual room for exercising the real ranging pipeline without microphones. Synthesizes what
* each device would have recorded — propagation delay, reflections, noise, per-device clock offset
* and unknown output latency — then runs the same EchoDsp code the page runs.
*/
const EchoSim = {
DEFAULTS: {
sampleRate: 48000,
slotSeconds: 0.4,
tailSeconds: 0.5,
speedOfSound: 343,
noiseAmplitude: 0.01,
referenceGain: 0.5,
minimumPathMetres: 0.25,
maximumOutputLatencySeconds: 0.2,
chirp: { durationSeconds: 0.05, startHz: 2000, endHz: 8000 },
reflectionsPerPath: 2,
reflectionExtraRange: [0.4, 4.0],
reflectionGainRange: [0.2, 0.8],
seed: 20260730
},
randomGenerator(seed) {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let mixed = Math.imul(state ^ (state >>> 15), 1 | state);
mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed;
return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296;
};
},
separation(first, second) {
return Math.hypot(first[0] - second[0], first[1] - second[1]);
},
buildConfiguration(overrides = {}) {
const config = { ...this.DEFAULTS, ...overrides };
const count = config.positions.length;
config.chirp = { ...this.DEFAULTS.chirp, ...(overrides.chirp ?? {}) };
config.epsilon ??= new Array(count).fill(0.05);
config.clockOffsets ??= config.positions.map((_, i) => i * 7919);
config.outputLatencies ??= config.positions.map((_, i) => 0.02 + 0.03 * i);
config.scheduleJitter ??= config.positions.map((_, i) => 0.004 * i);
config.reflections ??= this.buildReflectionTable(config);
return config;
},
/**
* Multipath for every source-to-listener path independently. Giving every path the same echo
* would be worthless as a test: an identical bias on all four arrivals cancels out of the
* range formula, so a uniform echo model hides exactly the error it is supposed to expose.
*/
buildReflectionTable(config) {
const random = this.randomGenerator(config.seed ^ 0x5f3759df);
const spread = (range, value) => range[0] + value * (range[1] - range[0]);
return config.positions.map(() =>
config.positions.map(() =>
Array.from({ length: config.reflectionsPerPath }, () => ({
extraMetres: spread(config.reflectionExtraRange, random()),
gain: spread(config.reflectionGainRange, random())
}))
)
);
},
reflectionsFor(config, source, listener) {
return Array.isArray(config.reflections[0]) ? config.reflections[source][listener] : config.reflections;
},
/** One recording per device, plus the index each device believes it started playing at. */
synthesizeRound(config) {
const { positions, sampleRate, slotSeconds, tailSeconds } = config;
const random = this.randomGenerator(config.seed);
const template = EchoDsp.makeChirp({ sampleRate, ...config.chirp });
const maximumOffset = Math.max(...config.clockOffsets);
const length = Math.ceil((positions.length * slotSeconds + tailSeconds) * sampleRate) + maximumOffset;
const devices = positions.map((_, index) => ({
recording: this.noiseBuffer(length, config.noiseAmplitude, random),
ownSearchStart: Math.round((index * slotSeconds + config.scheduleJitter[index]) * sampleRate) + config.clockOffsets[index]
}));
for (let source = 0; source < positions.length; source++)
for (let listener = 0; listener < positions.length; listener++)
this.mixArrivals(devices[listener].recording, template, config, source, listener);
return { devices, template };
},
noiseBuffer(length, amplitude, random) {
const buffer = new Float32Array(length);
for (let i = 0; i < length; i++) buffer[i] = (random() * 2 - 1) * amplitude;
return buffer;
},
mixArrivals(recording, template, config, source, listener) {
const emissionSeconds =
source * config.slotSeconds + config.scheduleJitter[source] + config.outputLatencies[source];
const directMetres =
source === listener ? config.epsilon[source] : this.separation(config.positions[source], config.positions[listener]);
const paths = [
{ metres: directMetres, gain: 1 },
...this.reflectionsFor(config, source, listener).map(({ extraMetres, gain }) => ({
metres: directMetres + extraMetres,
gain
}))
];
for (const path of paths) {
const arrival = emissionSeconds + path.metres / config.speedOfSound;
const amplitude =
(config.referenceGain / Math.max(path.metres, config.minimumPathMetres)) * path.gain;
this.addAt(recording, template, Math.round(arrival * config.sampleRate) + config.clockOffsets[listener], amplitude);
}
return recording;
},
addAt(recording, template, offset, amplitude) {
const start = Math.max(0, offset);
const end = Math.min(recording.length, offset + template.length);
for (let i = start; i < end; i++) recording[i] += template[i - offset] * amplitude;
return recording;
},
/** Run every device's recording through detection and return one report per device. */
detectAll({ devices, template }, config) {
const slotSamples = Math.round(config.slotSeconds * config.sampleRate);
const searchSamples = Math.round(
(config.maximumOutputLatencySeconds + config.chirp.durationSeconds + 0.05) * config.sampleRate
);
return devices.map((device, slot) => {
const envelope = EchoDsp.matchedFilterEnvelope(device.recording, template);
const detected = EchoDsp.detectSlotPeaks({
envelope,
slotCount: devices.length,
slotSamples,
ownSlot: slot,
ownSearchStart: device.ownSearchStart,
ownSearchSamples: searchSamples,
peakOptions: config.peakOptions ?? {}
});
return {
deviceId: `sim-${slot}`,
slot,
sampleRate: config.sampleRate,
epsilon: config.epsilon[slot],
peaks: (detected?.peaks ?? new Array(devices.length).fill(null)).map(peak => peak?.index ?? null)
};
});
},
/** Synthesize, detect and solve, reporting recovered geometry against the ground truth. */
runRound(overrides = {}) {
const config = this.buildConfiguration(overrides);
const round = this.synthesizeRound(config);
const reports = this.detectAll(round, config);
const solved = EchoDsp.solveRound(reports, { speedOfSound: config.speedOfSound });
return {
config,
reports,
...solved,
distanceErrors: this.distanceErrors(solved.matrix, config),
positionErrors: this.positionErrors(solved, config)
};
},
distanceErrors(matrix, config) {
const errors = [];
for (let i = 0; i < matrix.length; i++)
for (let j = i + 1; j < matrix.length; j++) {
const truth = this.separation(config.positions[i], config.positions[j]);
errors.push({
pair: [i, j],
truth,
measured: matrix[i][j],
error: matrix[i][j] === null ? null : matrix[i][j] - truth
});
}
return errors;
},
positionErrors({ keep, points }, config) {
if (points.length !== keep.length || points.length < 2) return [];
const truth = keep.map(index => config.positions[index]);
const aligned = EchoDsp.alignToReference(points, truth);
return aligned.map((point, i) => this.separation(point, truth[i]));
},
worstDistanceError(result) {
const magnitudes = result.distanceErrors.map(({ error }) => (error === null ? Infinity : Math.abs(error)));
return magnitudes.length === 0 ? 0 : Math.max(...magnitudes);
},
worstPositionError(result) {
return result.positionErrors.length === 0 ? Infinity : Math.max(...result.positionErrors);
}
};
if (typeof window !== "undefined") window.EchoSim = EchoSim;
@@ -1,282 +0,0 @@
/*
* Page wiring for /echo. Renders what the session measures: the matched-filter trace with the
* arrivals it picked, the pairwise ranges, and — while only two devices are present — one large
* number, because that number is the whole measurement.
*/
const EchoPage = {
HISTORY_LENGTH: 12,
elements: {},
lastSolved: null,
lastDiagnostics: null,
history: [],
roundsSeen: 0,
start() {
this.elements = {
room: document.getElementById("echoRoom"),
name: document.getElementById("echoName"),
join: document.getElementById("echoJoin"),
leave: document.getElementById("echoLeave"),
status: document.getElementById("echoStatus"),
warnings: document.getElementById("echoWarnings"),
readout: document.getElementById("echoReadout"),
pairs: document.getElementById("echoPairs"),
roster: document.getElementById("echoRoster"),
trace: document.getElementById("echoTrace"),
epsilon: document.getElementById("echoEpsilon"),
shareLink: document.getElementById("echoShareLink"),
diagnostics: document.getElementById("echoDiagnostics")
};
this.elements.room.value = new URLSearchParams(location.search).get("room") ?? this.randomCode();
this.elements.epsilon.value = EchoSession.loadEpsilon();
this.elements.epsilon.addEventListener("change", () => this.applyEpsilon());
this.elements.join.addEventListener("click", () => this.join());
this.elements.leave.addEventListener("click", () => this.leave());
return this;
},
randomCode() {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
return Array.from({ length: 4 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join("");
},
applyEpsilon() {
const metres = Number(this.elements.epsilon.value);
if (!Number.isFinite(metres) || metres < 0) return null;
EchoSession.setEpsilon(metres);
return metres;
},
async join() {
const roomCode = this.elements.room.value.trim().toUpperCase();
if (!roomCode) return null;
this.setStatus("Requesting the microphone…");
this.elements.join.disabled = true;
try {
const joined = await EchoSession.join(roomCode, this.deviceName(), { onUpdate: update => this.handle(update) });
this.showJoined(joined);
return joined;
} catch (error) {
this.setStatus(`Could not start: ${error.message}`);
this.elements.join.disabled = false;
return null;
}
},
deviceName() {
const typed = this.elements.name.value.trim();
if (typed) return typed;
return /android|iphone|ipad|mobile/i.test(navigator.userAgent) ? "phone" : "laptop";
},
showJoined(joined) {
this.elements.leave.hidden = false;
this.elements.room.disabled = true;
this.elements.name.disabled = true;
this.elements.shareLink.textContent = `${location.origin}/echo?room=${joined.roomCode}`;
this.elements.shareLink.href = `/echo?room=${joined.roomCode}`;
this.renderWarnings(joined.audio);
this.renderRoster(joined.room.devices);
this.setStatus(`Listening at ${joined.audio.sampleRate}Hz. Open the same room on another device.`);
return joined;
},
async leave() {
await EchoSession.leave();
this.elements.leave.hidden = true;
this.elements.join.disabled = false;
this.elements.room.disabled = false;
this.elements.name.disabled = false;
this.setStatus("Left the room.");
return true;
},
handle(update) {
if (update.kind === "room") return this.renderRoster(update.room.devices);
if (update.kind === "envelope") {
this.renderDiagnostics(update.diagnostics);
return this.renderTrace(update);
}
if (update.kind === "solved") return this.renderSolved(update);
return null;
},
/**
* Per-slot residual is the diagnostic that matters: it is how far each chirp landed from where
* its slot said it would. Steady residuals mean the arrivals are being attributed correctly;
* residuals jumping by more than a slot mean they are not, and every range is then meaningless.
*/
renderDiagnostics(diagnostics) {
if (!diagnostics) return null;
this.lastDiagnostics = diagnostics;
const slots = diagnostics.slots
.map(slot => {
const label = slot.own ? "self" : slot.deviceId;
const residual = slot.residualMs === null ? "missed" : `${slot.residualMs > 0 ? "+" : ""}${slot.residualMs}ms`;
return `<li class="${slot.residualMs === null ? "echo-missed" : ""}">${label} · ${residual}${slot.snr === null ? "" : ` · snr ${slot.snr}`}</li>`;
})
.join("");
this.elements.diagnostics.innerHTML = `
<ul class="echo-slots">${slots}</ul>
<p class="echo-diag-line">
capture lag ${diagnostics.captureLagMs}ms · output latency ${diagnostics.outputLatencyMs}ms ·
clock offset ${diagnostics.serverOffsetMs}ms · rounds sat out ${diagnostics.skippedRounds} ·
input peak ${diagnostics.inputPeak}${diagnostics.clipping ? " <strong>CLIPPING</strong>" : ""}
</p>`;
return diagnostics;
},
recordHistory(metres) {
this.history.push(metres);
if (this.history.length > this.HISTORY_LENGTH) this.history.shift();
const spread = Math.max(...this.history) - Math.min(...this.history);
return { spread, count: this.history.length };
},
setStatus(message) {
this.elements.status.textContent = message;
return message;
},
renderWarnings(audio) {
this.elements.warnings.innerHTML = "";
for (const warning of audio.warnings) {
const item = document.createElement("p");
item.className = "echo-warning";
item.textContent = warning;
this.elements.warnings.appendChild(item);
}
return audio.warnings.length;
},
renderRoster(devices) {
this.elements.roster.innerHTML = "";
for (const device of devices) {
const row = document.createElement("li");
row.className = device.deviceId === EchoSession.deviceId ? "echo-device echo-device-self" : "echo-device";
row.textContent = `${device.displayName} · ${device.sampleRate}Hz`;
this.elements.roster.appendChild(row);
}
if (devices.length < 2) this.setStatus("Waiting for a second device to join this room.");
return devices.length;
},
renderSolved({ result, reports, solved }) {
this.roundsSeen++;
const raw = EchoDsp.buildDistanceMatrix(
reports.map(report => ({ ...report, epsilon: 0 })),
{ speedOfSound: EchoDsp.speedOfSound(EchoSession.TEMPERATURE_CELSIUS) }
);
this.lastSolved = { result, reports, solved, raw };
this.renderReadout(reports, solved, raw);
this.renderPairs(reports, solved, raw);
this.setStatus(`Round ${this.roundsSeen} · ${reports.length} of ${EchoSession.devices.length} devices reported`);
return solved;
},
renderReadout(reports, solved, raw) {
const readout = this.elements.readout;
if (reports.length !== 2 || solved.matrix[0][1] === null) {
readout.innerHTML = `<span class="echo-readout-idle">${reports.length < 2 ? "waiting for a pair" : "measuring…"}</span>`;
return null;
}
const corrected = solved.matrix[0][1];
const { spread, count } = this.recordHistory(corrected);
readout.innerHTML = `
<span class="echo-metres">${corrected.toFixed(2)}<small>m</small></span>
<span class="echo-readout-detail">
raw ${raw[0][1].toFixed(3)}m · calibration +${(corrected - raw[0][1]).toFixed(3)}m ·
spread over last ${count} ${spread.toFixed(2)}m
</span>`;
return corrected;
},
renderPairs(reports, solved, raw) {
const rows = [];
for (let i = 0; i < reports.length; i++)
for (let j = i + 1; j < reports.length; j++) {
const dropped = !solved.keep.includes(i) || !solved.keep.includes(j);
const measured = solved.matrix[i][j];
rows.push(`<tr class="${dropped ? "echo-dropped" : ""}">
<td>${reports[i].deviceId} ↔ ${reports[j].deviceId}</td>
<td>${measured === null ? "—" : measured.toFixed(3) + " m"}</td>
<td>${raw[i][j] === null ? "—" : raw[i][j].toFixed(3) + " m"}</td>
</tr>`);
}
this.elements.pairs.innerHTML = rows.length
? `<table><thead><tr><th>pair</th><th>range</th><th>raw</th></tr></thead><tbody>${rows.join("")}</tbody></table>`
: "";
return rows.length;
},
/** The matched-filter trace, with a marker on each arrival the detector accepted. */
renderTrace({ envelope, detected, round }) {
const canvas = this.elements.trace;
const context = canvas.getContext("2d");
const width = (canvas.width = canvas.clientWidth);
const height = (canvas.height = 160);
const peak = EchoDsp.maxInRange(envelope, 0, envelope.length).value || 1;
context.clearRect(0, 0, width, height);
context.strokeStyle = "rgba(9, 255, 0, 0.75)";
context.beginPath();
const bucket = envelope.length / width;
for (let x = 0; x < width; x++) {
const start = Math.floor(x * bucket);
const highest = EchoDsp.maxInRange(envelope, start, Math.min(envelope.length, Math.floor(start + bucket))).value;
const y = height - (highest / peak) * (height - 8) - 4;
x === 0 ? context.moveTo(x, y) : context.lineTo(x, y);
}
context.stroke();
this.drawMarkers(context, detected, round, envelope.length, width, height);
return canvas;
},
drawMarkers(context, detected, round, envelopeLength, width, height) {
if (!detected) return null;
context.font = "11px 'Cascadia Code', monospace";
detected.peaks.forEach((peak, slot) => {
if (!peak) return;
const x = (peak.index / envelopeLength) * width;
const own = slot === round.ownSlot;
context.strokeStyle = own ? "#5fff5f" : "rgba(255, 255, 255, 0.55)";
context.fillStyle = context.strokeStyle;
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
context.fillText(own ? `self (${slot})` : `slot ${slot}`, x + 4, 14 + slot * 13);
});
return detected.peaks.length;
}
};
document.addEventListener("DOMContentLoaded", () => EchoPage.start());
@@ -0,0 +1,380 @@
// The bar a dealer's thumb lives on while a hand is running: who's to act, what it
// costs them, and one tap per decision. Sits at the bottom of the screen on a phone
// so it's reachable without looking, and re-renders after every action because the
// next player's numbers are different.
const ActionBar = {
state: null,
hooks: null,
raiseOpen: false,
raiseDraft: null,
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
this.render();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
isRunning() {
return Betting.liveSeats(this.state).length > 0;
},
render() {
const host = document.getElementById("actionBar");
if (!host) return;
host.innerHTML = "";
if (!this.isRunning()) {
host.hidden = true;
document.body.classList.remove("hasActionBar");
this.stopTankTimer();
this.tankSeatIndex = null;
return;
}
host.hidden = false;
document.body.classList.add("hasActionBar");
host.appendChild(this.renderStatus());
const breakdown = this.renderPotBreakdown();
if (breakdown) host.appendChild(breakdown);
if (Betting.isHandOver(this.state)) {
host.appendChild(this.renderHandOver());
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
host.appendChild(this.renderStreetDone());
} else if (this.raiseOpen) {
host.appendChild(this.renderRaisePad());
} else {
host.appendChild(this.renderActions());
}
this.syncTankTimer();
},
renderStatus() {
const row = document.createElement("div");
row.className = "actionStatus";
const street = document.createElement("span");
street.className = "actionStreet";
street.textContent = Betting.streetLabel(this.state.street);
row.appendChild(street);
const pot = document.createElement("span");
pot.className = "actionPot";
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
row.appendChild(pot);
// How many are still in the hand — part of the standard per-street
// announcement, and the number a dealer is asked for most often.
const remaining = Betting.liveSeats(this.state).length;
const players = document.createElement("span");
players.className = "actionPlayers";
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
row.appendChild(players);
const index = this.state.actingSeatIndex;
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
const player = this.state.seats[index];
const who = document.createElement("span");
who.className = "actionWho";
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
row.appendChild(who);
const tank = document.createElement("span");
tank.className = "actionTank";
tank.id = "actionTank";
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
row.appendChild(tank);
}
// A standing control rather than a toast per action: a dealer taps this bar
// constantly, and a toast after every fold would be noise they'd learn to
// ignore — which is exactly when they'd miss the one that mattered.
const undoLabel = this.hooks.lastActionLabel();
if (undoLabel) {
const undo = document.createElement("button");
undo.type = "button";
undo.className = "actionUndo";
undo.title = `Undo ${undoLabel}`;
undo.textContent = `↶ ${undoLabel}`;
undo.addEventListener("click", () => this.hooks.undoLast());
row.appendChild(undo);
}
return row;
},
// ---- Think timer ----
//
// A player who hasn't noticed the action is on them costs more time than anything
// else a dealer does, so the bar shows how long the current player has been in the
// tank. It's a stopwatch, not a shot clock: nothing happens when it grows, it just
// tells the dealer when it's worth a nudge.
NUDGE_AFTER_S: 30,
SLOW_AFTER_S: 60,
tankSeatIndex: null,
tankStartedAt: null,
tankInterval: null,
// Restarts only when the action actually moves — a re-render for any other reason
// (a chip edit, a name change) must not put a thinking player back to zero.
syncTankTimer() {
const index = this.state.actingSeatIndex;
const running = index !== null && index !== undefined && !Betting.isHandOver(this.state);
if (!running) {
this.stopTankTimer();
this.tankSeatIndex = null;
return;
}
if (this.tankSeatIndex !== index) {
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
}
if (!this.tankInterval) {
this.tankInterval = setInterval(() => this.paintTank(), 1000);
}
this.paintTank();
},
stopTankTimer() {
if (this.tankInterval) {
clearInterval(this.tankInterval);
this.tankInterval = null;
}
},
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
// buttons under the dealer's thumb.
paintTank() {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
const mins = Math.floor(seconds / 60);
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
},
// Shown only once someone is all-in and the money has actually split — a single
// undivided pot needs no announcement, and a line that's always there stops being
// read.
renderPotBreakdown() {
// Only worth showing once someone is actually all-in. Mid-street the amounts in
// front of players are unequal all the time — the blinds alone are unequal —
// and layering that would announce a side pot on every hand before anyone had
// even acted.
const someoneAllIn = this.state.seats.some(
player => player.inHand && Betting.handTotal(player) > 0 && Betting.stackOf(this.state, player) <= 0
);
if (!someoneAllIn) return null;
const contributions = this.state.seats.map((player, index) => ({
id: player.id,
index,
contribution: Betting.handTotal(player),
folded: player.folded,
}));
const nameById = new Map(
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
);
return Render.buildPotBreakdown(
PotCalculator.layers(contributions),
PotCalculator.uncalledReturns(contributions),
id => nameById.get(id)
);
},
// Which key presses each button. Shown on the button itself and hidden on touch
// devices — a shortcut nobody can discover may as well not exist, and a key badge
// on a phone is just clutter.
KEY_HINTS: {
actionFold: "F",
actionCheck: "C",
actionCall: "C",
actionRaise: "R",
actionAdvance: "↵",
raiseConfirm: "↵",
raiseCancel: "esc",
},
button(label, className, onClick, sublabel) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `actionBtn ${className}`;
const main = document.createElement("span");
main.className = "actionBtnLabel";
main.textContent = label;
btn.appendChild(main);
const hint = this.KEY_HINTS[className];
if (hint) {
const kbd = document.createElement("kbd");
kbd.className = "actionKey";
kbd.textContent = hint;
btn.appendChild(kbd);
}
if (sublabel) {
const sub = document.createElement("span");
sub.className = "actionBtnSub";
sub.textContent = sublabel;
btn.appendChild(sub);
}
btn.addEventListener("click", onClick);
return btn;
},
// Every action taken from the bar goes through here, which is also where it gets
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
// tool a dealer can't talk their way out of.
commit(label, mutate) {
this.hooks.beginAction(label);
mutate();
this.hooks.save(this.state);
this.hooks.rerender();
},
renderActions() {
const index = this.state.actingSeatIndex;
const player = this.state.seats[index];
const owed = Betting.toCall(this.state, player);
const stack = Betting.stackOf(this.state, player);
const who = HoldemState.displayName(player, index);
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
if (owed <= 0) {
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index))));
} else if (owed >= stack) {
// Calling would put them all-in, so say that rather than quoting a number
// they can't cover.
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
} else {
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
}
if (stack > owed) {
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
this.raiseOpen = true;
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
this.render();
}));
}
return row;
},
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
// button, and only reaches for digits when the table does something odd.
renderRaisePad() {
const index = this.state.actingSeatIndex;
const player = this.state.seats[index];
const stack = Betting.stackOf(this.state, player);
const maxTo = Betting.round(Betting.committed(player) + stack);
const pot = Betting.potSoFar(this.state);
const minTo = Math.min(Betting.minRaiseTo(this.state), maxTo);
const wrap = document.createElement("div");
wrap.className = "raisePad";
const quick = document.createElement("div");
quick.className = "raiseQuick";
const options = [
{ label: "Min", value: minTo },
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
{ label: "All-in", value: maxTo },
];
options.forEach(option => {
const value = Math.max(minTo, Math.min(option.value, maxTo));
const btn = this.button(option.label, "raiseQuickBtn", () => {
this.raiseDraft = value;
this.render();
}, Money.format(value));
if (Betting.round(this.raiseDraft) === value) btn.classList.add("selected");
quick.appendChild(btn);
});
wrap.appendChild(quick);
const entry = document.createElement("div");
entry.className = "raiseEntry";
const input = document.createElement("input");
input.type = "text";
input.inputMode = "decimal";
input.className = "raiseInput";
input.value = this.raiseDraft ?? minTo;
input.setAttribute("aria-label", "Raise to amount");
input.addEventListener("input", () => {
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
});
entry.appendChild(input);
entry.appendChild(this.button("Cancel", "raiseCancel", () => {
this.raiseOpen = false;
this.render();
}));
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
this.raiseOpen = false;
const who = HoldemState.displayName(player, index);
this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target));
}));
wrap.appendChild(entry);
return wrap;
},
renderStreetDone() {
const row = document.createElement("div");
row.className = "actionButtons";
const isRiver = this.state.street === "river";
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
row.appendChild(this.button(label, "actionAdvance", () => {
if (isRiver) {
this.hooks.resolvePot();
return;
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
}));
return row;
},
renderHandOver() {
const row = document.createElement("div");
row.className = "actionButtons";
const live = Betting.liveSeats(this.state);
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
row.appendChild(this.button(
winner ? `Award pot to ${winner}` : "Resolve pot",
"actionAdvance",
() => this.hooks.resolvePot()
));
return row;
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = ActionBar;
}
@@ -0,0 +1,92 @@
// "Add chips" — a rebuy, a top-up, or the first buy-in of the night.
//
// Quick amounts come off the table's starting stack, because a rebuy is almost
// always one buy-in, sometimes half or two. The keypad is there for the times it
// isn't. Recorded as cash in as well as chips, so at the end of the night the
// dealer can say what each player actually handed over.
const AddChips = {
state: null,
onConfirm: null,
seatIndex: null,
draft: "",
open(state, seatIndex, onConfirm) {
this.state = state;
this.seatIndex = seatIndex;
this.onConfirm = onConfirm;
this.draft = String(this.baseAmount());
this.render();
document.getElementById("addChipsOverlay").hidden = false;
},
close() {
document.getElementById("addChipsOverlay").hidden = true;
},
// The table's starting stack if there is one, otherwise something sane to nudge
// rather than an empty box.
baseAmount() {
const configured = Number(this.state.defaultStack);
if (!Number.isNaN(configured) && configured > 0) return configured;
const bb = Number(this.state.bigBlind) || 0;
return bb > 0 ? bb * 100 : 100;
},
quickAmounts() {
const base = this.baseAmount();
return [
{ label: "½ buy-in", value: Math.round(base / 2 * 100) / 100 },
{ label: "Buy-in", value: base },
{ label: "2 buy-ins", value: base * 2 },
];
},
render() {
const player = this.state.seats[this.seatIndex];
document.getElementById("addChipsTitle").textContent =
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
const stack = Betting.stackOf(this.state, player);
const boughtIn = Number(player.buyIns) || 0;
document.getElementById("addChipsSubtitle").textContent = boughtIn > 0
? `${Money.format(stack)} in front · ${Money.format(boughtIn)} in so far`
: `${Money.format(stack)} in front`;
const quick = document.getElementById("addChipsQuick");
quick.innerHTML = "";
this.quickAmounts().forEach(option => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "addChipsQuickBtn";
btn.classList.toggle("selected", Number(this.draft) === option.value);
const label = document.createElement("span");
label.className = "addChipsQuickLabel";
label.textContent = option.label;
const amount = document.createElement("span");
amount.className = "addChipsQuickAmount";
amount.textContent = Money.format(option.value);
btn.appendChild(label);
btn.appendChild(amount);
btn.addEventListener("click", () => {
this.draft = String(option.value);
this.render();
});
quick.appendChild(btn);
});
const input = document.getElementById("addChipsInput");
input.value = this.draft;
},
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 (typeof module !== "undefined" && module.exports) {
module.exports = AddChips;
}
@@ -0,0 +1,233 @@
// Live betting: who's to act, what it costs them, and where the money goes.
//
// This is bookkeeping, not a game — the dealer taps what the humans at the table
// actually did, and the tool keeps the arithmetic straight. It deliberately never
// blocks an action it thinks is "illegal"; a dealer overruling the maths is right
// more often than the maths is.
//
// Money model: `player.committed` is what a seat has put in on the current street.
// When a street closes it sweeps into `player.amountOverride`, which is the seat's
// total for the hand — the same field the pot badge, the pot wizard, the standings
// and the CSV already read. So the betting bar is a fast way to fill in a field
// that already existed, and nothing downstream had to change.
const Betting = {
STREETS: ["preflop", "flop", "turn", "river"],
streetLabel(street) {
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
},
round(amount) {
return Math.round((Number(amount) || 0) * 100) / 100;
},
// ---- Reading the table ----
liveSeats(state) {
return state.seats
.map((player, index) => ({ player, index }))
.filter(s => s.player.inHand && !s.player.folded);
},
committed(player) {
return this.round(player.committed || 0);
},
// Everything this seat has put in across the whole hand, including the street
// still in progress.
handTotal(player) {
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
},
stackOf(state, player) {
const own = player.stack;
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
const fallback = Number(state.defaultStack);
return Number.isNaN(fallback) ? 0 : this.round(fallback);
},
toCall(state, player) {
return Math.max(0, this.round((state.currentBet || 0) - this.committed(player)));
},
// A raise has to at least match the last one; before any raise, the big blind
// is the yardstick.
minRaiseTo(state) {
const increment = this.round(state.lastRaiseSize || state.bigBlind || 0);
return this.round((state.currentBet || 0) + (increment || 1));
},
potSoFar(state) {
return this.round(
state.seats.reduce((sum, p) => sum + this.handTotal(p), 0)
);
},
// ---- Moving the action ----
nextLiveIndex(state, fromIndex) {
const count = state.seatCount;
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;
}
return null;
},
seatAt(state, fromIndex, step) {
const count = state.seatCount;
const delta = state.direction === "counterclockwise" ? -step : step;
return ((fromIndex + delta) % count + count) % count;
},
// A street is done when everyone still live has either matched the bet or has
// no chips left to argue with, and everyone has had a turn since the last raise.
isStreetComplete(state) {
const live = this.liveSeats(state);
if (live.length <= 1) return true;
const unsettled = live.filter(s => {
const allIn = this.stackOf(state, s.player) <= 0;
const matched = this.toCall(state, s.player) <= 0;
return !allIn && (!matched || !s.player.hasActed);
});
return unsettled.length === 0;
},
// ---- Actions ----
// Moves `amount` from a seat's stack into the middle for this street. Returns
// what actually moved, which is less than asked for if it puts them all-in.
putIn(state, index, amount) {
const player = state.seats[index];
const stack = this.stackOf(state, player);
const moved = this.round(Math.min(Math.max(0, amount), stack));
player.stack = this.round(stack - moved);
player.committed = this.round(this.committed(player) + moved);
return moved;
},
fold(state, index) {
state.seats[index].folded = true;
state.seats[index].hasActed = true;
this.passAction(state, index);
},
check(state, index) {
state.seats[index].hasActed = true;
this.passAction(state, index);
},
call(state, index) {
const player = state.seats[index];
this.putIn(state, index, this.toCall(state, player));
player.hasActed = true;
this.passAction(state, index);
},
// `total` is what the seat's street commitment becomes — "raise to", the way a
// dealer says it out loud, not "raise by".
raiseTo(state, index, total) {
const player = state.seats[index];
const target = this.round(total);
const delta = this.round(target - this.committed(player));
const moved = this.putIn(state, index, delta);
const newBet = this.committed(player);
if (newBet > (state.currentBet || 0)) {
state.lastRaiseSize = this.round(newBet - (state.currentBet || 0));
state.currentBet = newBet;
// A raise reopens the action: everyone else owes a response again.
state.seats.forEach((p, i) => {
if (i !== index && p.inHand && !p.folded) p.hasActed = false;
});
}
player.hasActed = true;
this.passAction(state, index);
return moved;
},
allIn(state, index) {
const player = state.seats[index];
return this.raiseTo(state, index, this.round(this.committed(player) + this.stackOf(state, player)));
},
passAction(state, index) {
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextLiveIndex(state, index);
},
// ---- Street and hand boundaries ----
// Pulls whatever is in front of the players into their hand totals. Anything that
// reads a seat's contribution — the pot wizard above all — has to run after this,
// or it will price the pot without the chips sitting on the current street.
sweepToHandTotals(state) {
state.seats.forEach(player => {
const moved = this.committed(player);
if (moved > 0) {
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
}
player.committed = 0;
player.hasActed = false;
});
},
// Sweeps the street's chips into each seat's hand total and opens the next one.
closeStreet(state) {
this.sweepToHandTotals(state);
state.currentBet = 0;
state.lastRaiseSize = 0;
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;
},
isHandOver(state) {
return this.liveSeats(state).length <= 1;
},
// 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) {
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
state.seats.forEach(player => {
player.committed = 0;
player.hasActed = false;
});
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);
if (sb > 0 && state.seats[sbIndex].inHand) this.putIn(state, sbIndex, sb);
if (bb > 0 && state.seats[bbIndex].inHand) 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);
},
// Winners get their share put back in front of them.
creditWinnings(state, payouts) {
payouts.forEach(payout => {
const player = state.seats.find(p => p.id === payout.id);
if (!player) return;
player.stack = this.round(this.stackOf(state, player) + payout.amount);
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = Betting;
}
@@ -0,0 +1,131 @@
// Tap a card instead of typing it.
//
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
// card land in two players' hands. The picker is one tap per card, greys out cards
// already dealt somewhere else at the table, and stores exactly the same two-character
// string the rest of the tool has always read.
const CardPicker = {
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
SUITS: [
{ code: "S", symbol: "♠", red: false },
{ code: "H", symbol: "♥", red: true },
{ code: "D", symbol: "♦", red: true },
{ code: "C", symbol: "♣", red: false },
],
state: null,
hooks: null,
seatIndex: null,
cardIndex: 0,
open(state, hooks, seatIndex, cardIndex) {
this.state = state;
this.hooks = hooks;
this.seatIndex = seatIndex;
this.cardIndex = cardIndex;
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
close() {
document.getElementById("cardPickerOverlay").hidden = true;
},
// Every card sitting in front of anyone else. The slot being edited is excluded,
// so re-picking the card that's already there isn't treated as a duplicate.
cardsInUse() {
const used = new Map();
this.state.seats.forEach((player, seat) => {
(player.cards || []).forEach((card, slot) => {
if (!card) return;
if (seat === this.seatIndex && slot === this.cardIndex) return;
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
});
});
return used;
},
assign(card) {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = card;
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
// Straight on to the second card — a dealer picking hole cards is always
// picking two, and making them re-open the picker doubles the taps.
if (this.cardIndex === 0 && !player.cards[1]) {
this.cardIndex = 1;
this.render();
return;
}
this.close();
},
clearSlot() {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = "";
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
const used = this.cardsInUse();
document.getElementById("cardPickerTitle").textContent =
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
// Which slot the next tap fills, switchable without closing the sheet.
const slots = document.getElementById("cardPickerSlots");
slots.innerHTML = "";
[0, 1].forEach(slot => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerSlot";
btn.classList.toggle("active", slot === this.cardIndex);
const value = player.cards[slot] || "";
btn.textContent = value || "—";
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
btn.addEventListener("click", () => {
this.cardIndex = slot;
this.render();
});
slots.appendChild(btn);
});
const grid = document.getElementById("cardPickerGrid");
grid.innerHTML = "";
this.RANKS.forEach(rank => {
this.SUITS.forEach(suit => {
const card = `${rank}${suit.code}`;
const takenBy = used.get(card);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
const rankEl = document.createElement("span");
rankEl.className = "pickerRank";
rankEl.textContent = rank;
const suitEl = document.createElement("span");
suitEl.className = "pickerSuit";
suitEl.textContent = suit.symbol;
btn.appendChild(rankEl);
btn.appendChild(suitEl);
btn.addEventListener("click", () => this.assign(card));
grid.appendChild(btn);
});
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = CardPicker;
}
@@ -0,0 +1,115 @@
// Toasts and confirmation dialogs, so the table never has to fall back on the
// browser's own alert boxes. A system confirm() steals focus, looks nothing like
// the rest of the page, and on a phone it's a full-screen interruption — all of
// which is a bad trade for a dealer trying to keep a game moving.
// A short-lived message at the bottom of the screen, optionally with one action
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
const Toast = {
DEFAULT_DURATION_MS: 8000,
timer: null,
show(message, options = {}) {
const host = document.getElementById("toastHost");
if (!host) return;
this.dismiss();
const toast = document.createElement("div");
toast.className = "toast";
toast.setAttribute("role", "status");
const text = document.createElement("span");
text.className = "toastText";
text.textContent = message;
toast.appendChild(text);
if (options.actionLabel && options.onAction) {
const action = document.createElement("button");
action.type = "button";
action.className = "toastAction";
action.textContent = options.actionLabel;
action.addEventListener("click", () => {
this.dismiss();
options.onAction();
});
toast.appendChild(action);
}
const close = document.createElement("button");
close.type = "button";
close.className = "toastClose";
close.setAttribute("aria-label", "Dismiss");
close.textContent = "✕";
close.addEventListener("click", () => this.dismiss());
toast.appendChild(close);
host.appendChild(toast);
// Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => toast.classList.add("toastVisible"));
this.timer = setTimeout(
() => this.dismiss(),
options.duration || this.DEFAULT_DURATION_MS
);
},
dismiss() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const host = document.getElementById("toastHost");
if (host) host.innerHTML = "";
},
};
// Styled stand-in for confirm(). Resolves true if the dealer goes through with it.
const Dialog = {
confirm({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false }) {
return new Promise(resolve => {
const overlay = document.getElementById("confirmOverlay");
const modal = overlay.querySelector("#confirmModal");
overlay.querySelector("#confirmTitle").textContent = title;
overlay.querySelector("#confirmMessage").textContent = message;
const confirmBtn = overlay.querySelector("#confirmAcceptBtn");
const cancelBtn = overlay.querySelector("#confirmCancelBtn");
confirmBtn.textContent = confirmLabel;
cancelBtn.textContent = cancelLabel;
confirmBtn.classList.toggle("holdemBtnDanger", danger);
confirmBtn.classList.toggle("holdemBtnAccent", !danger);
const finish = result => {
overlay.hidden = true;
confirmBtn.removeEventListener("click", onAccept);
cancelBtn.removeEventListener("click", onCancel);
overlay.removeEventListener("click", onBackdrop);
document.removeEventListener("keydown", onKey);
resolve(result);
};
const onAccept = () => finish(true);
const onCancel = () => finish(false);
const onBackdrop = e => {
if (e.target === overlay) finish(false);
};
const onKey = e => {
if (e.key === "Escape") finish(false);
if (e.key === "Enter") finish(true);
};
confirmBtn.addEventListener("click", onAccept);
cancelBtn.addEventListener("click", onCancel);
overlay.addEventListener("click", onBackdrop);
document.addEventListener("keydown", onKey);
overlay.hidden = false;
modal.focus();
confirmBtn.focus();
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Toast, Dialog };
}
@@ -0,0 +1,80 @@
// Turns the in-memory hand history into a CSV file the dealer can download for a
// paper trail — a house manager or a disputed chip count needs something that
// outlives the browser tab, not just a scrollable panel in the sidebar.
const HandHistoryExport = {
csvField(value) {
const str = String(value ?? "");
return /[",\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
},
formatTimestamp(timestamp) {
try {
return new Date(timestamp).toLocaleString();
} catch {
return "";
}
},
toCsv(handHistory, standings = []) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
.map(p => `${p.label}: ${Money.format(p.amount)}`)
.join("; ");
rows.push([
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.format(entry.pot),
payouts,
]);
});
// The settle-up numbers go in the same file as the hands they came from, so a
// dealer handing over one export isn't asked for a second one at cash-out.
if (standings.length > 0) {
rows.push([]);
rows.push(["Session Standings"]);
// Bought in and chips now are what a cash-out is settled from; paid in and
// won describe the hands. Both belong in the file the dealer hands over.
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
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),
]);
});
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
},
fileName() {
const now = new Date();
const pad = n => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
return `holdem-hand-history-${stamp}.csv`;
},
download(handHistory, standings = []) {
const csv = this.toCsv(handHistory, standings);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = this.fileName();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HandHistoryExport;
}
@@ -0,0 +1,327 @@
document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load();
// 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));
mutate(state.seats[index], index);
HoldemState.save(state);
Render.renderAll();
Toast.show(message, {
actionLabel: "Undo",
onAction: () => {
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
HoldemState.save(state);
Render.renderAll();
Toast.show("Undone.", { duration: 2500 });
},
duration: 6000,
});
};
const nameOf = index => HoldemState.displayName(state.seats[index], index);
const hooks = {
save: s => HoldemState.save(s),
rerender: () => Render.renderAll(),
onSeatDoubleTap: index => {
const player = state.seats[index];
if (!player.inHand) return;
if (player.folded) {
seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; });
return;
}
seatAction(index, `${nameOf(index)} folds.`, () => {
if (state.actingSeatIndex === index) Betting.fold(state, index);
else player.folded = true;
});
},
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
};
const seatMenuHooks = {
fold: index => hooks.onSeatDoubleTap(index),
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 => {
p.inHand = !dealtIn;
if (!p.inHand) p.folded = false;
});
},
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
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;
});
}),
};
Render.mount(state, hooks);
const openPotWizard = () => {
// Chips still in front of players belong in the pot being priced, so bank them
// before the wizard reads anyone's contribution.
Betting.sweepToHandTotals(state);
hooks.save(state);
Render.renderAll();
PotWizard.open(state, payouts => {
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts);
// Winnings go straight back in front of the player, so the next
// hand's stacks are already right.
Betting.creditWinnings(state, payouts);
}
HoldemState.startNextHand(state);
Betting.beginHand(state);
});
});
};
// A short history of actions taken from the bar, so a misclicked Fold can be taken
// back — and the one before it, since a dealer usually notices a beat late. Kept in
// memory only: this is for the mistake you just made, not last night's hand.
const MAX_UNDO_STEPS = 12;
const actionHistory = [];
ActionBar.mount(state, {
save: hooks.save,
rerender: hooks.rerender,
resolvePot: openPotWizard,
beginAction: label => {
actionHistory.push({ label, snapshot: JSON.parse(JSON.stringify(state)) });
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
},
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
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();
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
},
});
// Anything that ends the hand makes the per-action history meaningless — the
// snapshots refer to a hand that no longer exists.
const clearActionHistory = () => { actionHistory.length = 0; };
// Keyboard shortcuts press the very same buttons a dealer would tap, rather than
// calling the betting engine themselves — there's no second code path to drift out
// of step with what the bar is actually offering right now.
const pressInBar = selector => {
const btn = document.querySelector(`#actionBar ${selector}`);
if (!btn || btn.disabled) return false;
btn.click();
return true;
};
const quickRaiseButton = label =>
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
Shortcuts.mount({
fold: () => pressInBar(".actionFold"),
checkOrCall: () => pressInBar(".actionCheck") || pressInBar(".actionCall"),
raise: () => pressInBar(".actionRaise"),
allIn: () => {
// Open the raise pad first if it isn't already, then take its All-in amount.
if (!document.querySelector("#actionBar .raisePad") && !pressInBar(".actionRaise")) return false;
const quick = quickRaiseButton("All-in");
if (!quick) return false;
quick.click();
return true;
},
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance"),
undo: () => pressInBar(".actionUndo"),
cancel: () => pressInBar(".raiseCancel"),
});
// Moving to the next hand used to ask "are you sure?" every single time —
// the most-pressed button in a session, gated behind a system dialog. It now
// just happens, and the toast offers the way back for the few seconds anyone
// 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));
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.
clearActionHistory();
hooks.save(state);
Render.renderAll();
Toast.show(message, {
actionLabel: "Undo",
onAction: () => {
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
hooks.save(state);
Render.renderAll();
Toast.show("Undone.", { duration: 2500 });
},
});
};
document.getElementById("seatCountInput").addEventListener("change", e => {
HoldemState.resizeSeats(state, Number(e.target.value));
hooks.save(state);
Render.renderAll();
});
document.getElementById("directionSelect").addEventListener("change", e => {
state.direction = e.target.value;
hooks.save(state);
Render.renderSeats();
});
document.getElementById("basePotInput").addEventListener("input", e => {
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
hooks.save(state);
Render.renderAll();
});
document.getElementById("dedicatedDealerToggle").addEventListener("change", e => {
state.dedicatedDealer = e.target.checked;
hooks.save(state);
Render.renderSeats();
Render.renderTableHint();
});
document.getElementById("tableNotes").addEventListener("input", e => {
state.notes = e.target.value;
hooks.save(state);
});
document.getElementById("addChipColorBtn").addEventListener("click", () => {
state.chipLegend.push({
id: HoldemState.makeId("chip"),
color: "#888888",
value: 1,
});
hooks.save(state);
Render.renderAll();
});
document.getElementById("nextHandBtn").addEventListener("click", () => {
// startNextHand moves the button on by one; say where it landed so the
// dealer can confirm the rotation without hunting for the badge.
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
const where = state.dedicatedDealer
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`;
runUndoable(`Next hand — ${where}. Blinds posted.`, () => {
HoldemState.startNextHand(state);
Betting.beginHand(state);
});
});
document.getElementById("resetTableBtn").addEventListener("click", async () => {
const ok = await Dialog.confirm({
title: "Reset the whole table?",
message: "Every seat, chip colour and note goes back to defaults. The hand history and session standings are cleared too.",
confirmLabel: "Reset table",
danger: true,
});
if (!ok) return;
HoldemState.clear();
Object.assign(state, HoldemState.createDefault());
clearActionHistory();
Render.mount(state, hooks);
Toast.show("Table reset.", { duration: 2500 });
});
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
[
["smallBlindInput", "smallBlind"],
["bigBlindInput", "bigBlind"],
["defaultStackInput", "defaultStack"],
].forEach(([id, key]) => {
document.getElementById(id).addEventListener("input", e => {
state[key] = e.target.value === "" ? null : Number(e.target.value);
hooks.save(state);
Render.renderAll();
});
});
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
document.getElementById("addChipsOverlay").addEventListener("click", e => {
if (e.target.id === "addChipsOverlay") AddChips.close();
});
document.getElementById("addChipsInput").addEventListener("keydown", e => {
if (e.key === "Enter") AddChips.confirm();
});
const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; };
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
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 => {
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
});
document.getElementById("cardPickerCloseBtn").addEventListener("click", () => CardPicker.close());
document.getElementById("cardPickerClearBtn").addEventListener("click", () => CardPicker.clearSlot());
document.getElementById("cardPickerOverlay").addEventListener("click", e => {
if (e.target.id === "cardPickerOverlay") CardPicker.close();
});
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
document.getElementById("potWizardOverlay").addEventListener("click", e => {
if (e.target.id === "potWizardOverlay") PotWizard.close();
});
document.getElementById("clearHistoryBtn").addEventListener("click", async () => {
const ok = await Dialog.confirm({
title: "Clear the hand history?",
message: "This wipes every logged hand and the session standings, and puts the hand counter back to #1. Export first if anyone might want the numbers.",
confirmLabel: "Clear history",
danger: true,
});
if (!ok) return;
HoldemState.clearHistory(state);
hooks.save(state);
Render.renderAll();
Toast.show("Hand history cleared.", { duration: 2500 });
});
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
});
});
@@ -0,0 +1,22 @@
// 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.
const Money = {
format(amount) {
const value = Number(amount) || 0;
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
const value = Number(amount) || 0;
if (value > 0) return `+${this.format(value)}`;
if (value < 0) return `-${this.format(Math.abs(value))}`;
return this.format(0);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = Money;
}
@@ -0,0 +1,151 @@
// Pure side-pot math. No DOM, no globals besides the PotCalculator namespace, so the
// dealer's payout logic can be reasoned about (and unit tested) on its own.
//
// 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,
// 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
// who put in nothing at all.
buildEntries(players) {
return players
.filter(p => p.contribution > this.EPSILON)
.map(p => ({ id: p.id, remaining: p.contribution, folded: !!p.folded }));
},
totalPot(entries) {
return entries.reduce((sum, e) => sum + e.remaining, 0);
},
// Players still eligible to be picked as a winner for the money that's left.
getContenders(entries) {
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
},
// Splits the money on the table into the main pot and any side pots, with who can
// win each. Read-only — this is what a dealer says out loud before the showdown
// ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the
// announcement that goes missing far more often than the arithmetic goes wrong.
//
// Every distinct all-in amount opens a new layer: each player pays into a layer up
// 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);
if (live.length === 0) return [];
const heights = [...new Set(live.map(p => Math.round(p.contribution * 100)))].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 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 });
}
floor = height;
});
// Consecutive layers with the same set of eligible players are one pot to a
// dealer — nobody announces two side pots between the same two people.
const merged = [];
layers.forEach(layer => {
const previous = merged[merged.length - 1];
const sameEligibility =
previous &&
previous.eligible.length === layer.eligible.length &&
previous.eligible.every(id => layer.eligible.includes(id));
if (sameEligibility) {
previous.amount = Math.round((previous.amount + layer.amount) * 100) / 100;
} else {
merged.push({ ...layer });
}
});
return merged.map((layer, index) => ({
...layer,
label: index === 0 ? "Main pot" : `Side pot ${index}`,
}));
},
// 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.
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 [];
const cap = Math.max(...liveCents);
return contributing
.map(p => ({ id: p.id, amount: (Math.round(p.contribution * 100) - cap) / 100 }))
.filter(r => r.amount > this.EPSILON);
},
// Awards the current pot layer to `winnerIds` (one id, or several for a split/tie).
// The payout is capped at the smallest remaining contribution among the winners —
// that's the classic side-pot rule: an all-in player can only win what everyone at
// the table matched, and the excess rolls forward to the next round.
resolveRound(entries, winnerIds) {
const winners = entries.filter(e => winnerIds.includes(e.id) && e.remaining > this.EPSILON);
if (winners.length === 0) {
throw new Error("No valid winners selected for this round.");
}
const cap = Math.min(...winners.map(w => w.remaining));
let payoutPool = 0;
const updatedEntries = entries.map(e => {
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 };
});
payoutPool = Math.round(payoutPool * 100) / 100;
const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length;
const perWinner = winnerIds
.filter(id => winners.some(w => w.id === id))
.map(id => ({ id, amountCents: baseShareCents }));
// Odd cents (can't split evenly) go one at a time to the winners, in the order
// they were selected, so nothing is ever lost to rounding.
let i = 0;
while (leftoverCents > 0 && perWinner.length > 0) {
perWinner[i % perWinner.length].amountCents += 1;
leftoverCents--;
i++;
}
const result = perWinner.map(w => ({ id: w.id, amount: w.amountCents / 100 }));
const remainingContenders = this.getContenders(updatedEntries);
return {
payoutPool,
capApplied: cap,
perWinner: result,
updatedEntries,
remainingContenders,
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = PotCalculator;
}
@@ -0,0 +1,229 @@
// Walks the dealer through awarding the pot: "who won?", and if that player was
// all-in for less than everyone else, "who's next?" — repeating until every dollar
// in the pot has been assigned. Built on PotCalculator's pure round-resolution math.
const PotWizard = {
state: null,
onFinished: null,
entries: [],
playerLabelById: new Map(),
log: [],
open(state, onFinished) {
this.state = state;
this.onFinished = onFinished;
const players = state.seats.filter(
p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0
);
this.playerLabelById = new Map(
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
);
const contributions = players.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
folded: p.folded,
}));
this.entries = PotCalculator.buildEntries(contributions);
// Kept so the wizard can show the whole breakdown up front, not just the layer
// it happens to be asking about.
this.layers = PotCalculator.layers(contributions);
this.returns = PotCalculator.uncalledReturns(contributions);
this.log = [];
this.totals = new Map();
document.getElementById("potWizardOverlay").hidden = false;
this.renderStep();
},
close() {
document.getElementById("potWizardOverlay").hidden = true;
},
labelFor(player, seatIndex) {
const name = HoldemState.displayName(player, seatIndex);
const cards = (player.cards || []).filter(Boolean).map(c => Render.formatCard(c)).join(" ");
return cards ? `${name} (${cards})` : name;
},
addToTotal(id, amount) {
this.totals.set(id, (this.totals.get(id) || 0) + amount);
},
renderStep() {
// Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries);
while (contenders.length === 1) {
const result = PotCalculator.resolveRound(this.entries, [contenders[0].id]);
this.recordRound(result);
this.entries = result.updatedEntries;
contenders = PotCalculator.getContenders(this.entries);
}
const body = document.getElementById("potWizardBody");
body.innerHTML = "";
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
body.appendChild(this.renderLog());
const remaining = PotCalculator.totalPot(this.entries.filter(e => e.remaining > PotCalculator.EPSILON));
if (contenders.length === 0 && remaining > PotCalculator.EPSILON) {
const warning = document.createElement("p");
warning.className = "potWizardPrompt";
// Money left over because nobody could call it isn't a pot anyone won —
// it goes back to whoever put it in. Telling the dealer to un-fold someone
// to "win" it would be wrong.
const returned = (this.returns || []).reduce((sum, r) => sum + r.amount, 0);
if (Math.abs(returned - remaining) <= PotCalculator.EPSILON && returned > 0) {
const who = this.returns.map(r => this.playerLabelById.get(r.id)).join(", ");
warning.textContent = `${Money.format(remaining)} was never called — push it back to ${who} rather than paying it out.`;
} else {
warning.textContent = `${Money.format(remaining)} is still unassigned, but every remaining player is marked Folded. Uncheck Fold on whoever should win it.`;
}
body.appendChild(warning);
body.appendChild(this.renderSummary());
return;
}
if (contenders.length === 0 || remaining <= PotCalculator.EPSILON) {
body.appendChild(this.renderSummary());
return;
}
const round = document.createElement("div");
round.className = "potWizardRound";
const prompt = document.createElement("p");
prompt.className = "potWizardPrompt";
prompt.innerHTML = this.log.length === 0
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
round.appendChild(prompt);
const choices = document.createElement("div");
choices.className = "potWizardChoices";
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)}`;
label.appendChild(checkbox);
label.appendChild(text);
choices.appendChild(label);
});
round.appendChild(choices);
const hint = document.createElement("p");
hint.className = "fieldHint";
hint.textContent = "Check more than one to split the pot on a tie.";
round.appendChild(hint);
const confirmBtn = document.createElement("button");
confirmBtn.className = "holdemBtn holdemBtnAccent";
confirmBtn.textContent = "Confirm Winner(s)";
confirmBtn.addEventListener("click", () => {
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
if (selected.length === 0) return;
const result = PotCalculator.resolveRound(this.entries, selected);
this.recordRound(result);
this.entries = result.updatedEntries;
this.renderStep();
});
round.appendChild(confirmBtn);
body.appendChild(round);
},
recordRound(result) {
result.perWinner.forEach(w => this.addToTotal(w.id, w.amount));
this.log.push({
amount: result.payoutPool,
winners: result.perWinner.map(w => `${this.playerLabelById.get(w.id)} (${Money.format(w.amount)})`),
});
},
// The announcement, spelled out before anyone is asked to pick a winner: what the
// main pot is worth, what each side pot is worth, and who can win each.
renderBreakdown() {
const node = Render.buildPotBreakdown(
this.layers || [],
this.returns || [],
id => this.playerLabelById.get(id),
"Announce before the showdown"
);
if (!node) return document.createDocumentFragment();
node.classList.add("potBreakdownWizard");
return node;
},
renderLog() {
const wrap = document.createElement("div");
wrap.className = "potWizardLog";
if (this.log.length === 0) return wrap;
this.log.forEach((entry, idx) => {
const line = document.createElement("p");
line.className = "potWizardLogLine";
line.textContent = `Pot ${idx + 1}: ${Money.format(entry.amount)} → ${entry.winners.join(", ")}`;
wrap.appendChild(line);
});
return wrap;
},
renderSummary() {
const wrap = document.createElement("div");
wrap.className = "potWizardSummary";
const heading = document.createElement("h3");
heading.textContent = "Final Payouts";
wrap.appendChild(heading);
if (this.totals.size === 0) {
const empty = document.createElement("p");
empty.className = "fieldHint";
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
wrap.appendChild(empty);
} else {
const list = document.createElement("ul");
for (const [id, amount] of this.totals.entries()) {
const li = document.createElement("li");
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
list.appendChild(li);
}
wrap.appendChild(list);
}
const actions = document.createElement("div");
actions.className = "potWizardActions";
const applyBtn = document.createElement("button");
applyBtn.className = "holdemBtn holdemBtnAccent";
applyBtn.textContent = "Start Next Hand";
applyBtn.addEventListener("click", () => {
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
this.close();
this.onFinished(payouts);
});
actions.appendChild(applyBtn);
const closeBtn = document.createElement("button");
closeBtn.className = "holdemBtn holdemBtnGhost";
closeBtn.textContent = "Close";
closeBtn.addEventListener("click", () => this.close());
actions.appendChild(closeBtn);
wrap.appendChild(actions);
return wrap;
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = PotWizard;
}
@@ -0,0 +1,166 @@
// The quick-split screen: pot in, payout out, nothing to set up and nothing saved.
// A dealer opens this one-handed with players waiting, so every control is a tap and
// the answer updates as they type rather than behind a "calculate" button.
const QuickSplit = {
WAY_CHOICES: [2, 3, 4, 5],
CHIP_CHOICES: [
{ value: 0.01, label: "1¢" },
{ value: 0.25, label: "25¢" },
{ value: 1, label: "$1" },
{ value: 5, label: "$5" },
{ value: 25, label: "$25" },
],
MAX_WAYS: 12,
ways: 2,
chip: 1,
mount() {
this.potInput = document.getElementById("potInput");
this.clearPotBtn = document.getElementById("clearPotBtn");
this.renderWayChoices();
this.renderChipChoices();
this.potInput.addEventListener("input", () => {
this.sanitizePotInput();
this.update();
});
this.clearPotBtn.addEventListener("click", () => {
this.potInput.value = "";
this.potInput.focus();
this.update();
});
document.getElementById("resetSplitBtn").addEventListener("click", () => {
this.potInput.value = "";
this.ways = 2;
this.chip = 1;
this.renderWayChoices();
this.renderChipChoices();
this.potInput.focus();
this.update();
});
this.update();
},
// Keeps the field to something parseable without fighting the typist: digits and a
// single decimal point, at most two places.
sanitizePotInput() {
const cleaned = this.potInput.value
.replace(/[^0-9.]/g, "")
.replace(/(\..*)\./g, "$1")
.replace(/(\.\d\d)\d+/, "$1");
if (cleaned !== this.potInput.value) this.potInput.value = cleaned;
},
makeChoiceButton(label, isSelected, onPick) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "choiceChip";
btn.textContent = label;
btn.setAttribute("aria-pressed", String(isSelected));
btn.classList.toggle("selected", isSelected);
btn.addEventListener("click", onPick);
return btn;
},
renderWayChoices() {
const row = document.getElementById("waysChoices");
row.innerHTML = "";
this.WAY_CHOICES.forEach(count => {
row.appendChild(
this.makeChoiceButton(String(count), this.ways === count, () => {
this.ways = count;
this.renderWayChoices();
this.update();
})
);
});
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
// spend a row of buttons on it.
const more = document.createElement("div");
more.className = "waysStepper";
const minus = this.makeStepButton("−", () => this.stepWays(-1));
const plus = this.makeStepButton("+", () => this.stepWays(1));
const readout = document.createElement("span");
readout.className = "waysReadout";
readout.textContent = `${this.ways} ways`;
more.appendChild(minus);
more.appendChild(readout);
more.appendChild(plus);
row.appendChild(more);
},
makeStepButton(label, onPick) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "stepBtn";
btn.textContent = label;
btn.addEventListener("click", onPick);
return btn;
},
stepWays(delta) {
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
this.renderWayChoices();
this.update();
},
renderChipChoices() {
const row = document.getElementById("chipChoices");
row.innerHTML = "";
this.CHIP_CHOICES.forEach(choice => {
row.appendChild(
this.makeChoiceButton(choice.label, this.chip === choice.value, () => {
this.chip = choice.value;
this.renderChipChoices();
this.update();
})
);
});
},
update() {
const potValue = Number(this.potInput.value);
const pot = Number.isNaN(potValue) ? 0 : potValue;
this.clearPotBtn.hidden = this.potInput.value === "";
const result = SplitCalculator.split(pot, this.ways, this.chip);
document.getElementById("splitResultLabel").textContent =
`Each of ${result.players} players gets`;
document.getElementById("splitResultAmount").textContent = Money.format(result.each);
const oddNote = document.getElementById("splitOddNote");
if (result.valid && result.oddChips > 0) {
oddNote.textContent = result.oddChips === 1
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
`first ${result.oddChips} players left of the button`;
oddNote.hidden = false;
} else {
oddNote.hidden = true;
}
const dustNote = document.getElementById("splitDustNote");
if (result.valid && result.dust > 0) {
dustNote.textContent =
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
`pick a smaller denomination.`;
dustNote.hidden = false;
} else {
dustNote.hidden = true;
}
document.getElementById("splitResult").classList.toggle("hasAmount", result.valid && result.each > 0);
},
};
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
@@ -0,0 +1,716 @@
// All DOM rendering + drag/drop for the table. Talks to the outside world only through
// the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender()
// re-draws everything (used after structural changes like resizing seats or the chip
// legend).
//
// The table itself only shows compact seat "pucks" — name, cards, total, blind/dealer
// badges — because a full data-entry form (chip counts, custom fields, etc.) doesn't fit
// next to nine others arranged in a circle at any screen size. Clicking a puck opens the
// full form in the seat editor panel instead.
const Render = {
state: null,
hooks: null,
selectedSeatIndex: null,
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
this.selectedSeatIndex = null;
this.renderAll();
},
renderAll() {
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
this.selectedSeatIndex = null;
}
this.renderSeats();
this.renderSeatEditor();
this.renderChipLegend();
this.renderNotes();
this.updatePotBadge();
this.renderSetupFields();
this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
},
renderSetupFields() {
document.getElementById("seatCountInput").value = this.state.seatCount;
document.getElementById("directionSelect").value = this.state.direction;
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
},
renderTableHint() {
const hint = document.getElementById("tableHint");
// Gestures are invisible until someone tells you about them, and this is the
// only place on the table with room to say so.
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
},
renderNotes() {
const notesEl = document.getElementById("tableNotes");
if (document.activeElement !== notesEl) notesEl.value = this.state.notes || "";
},
updatePotBadge() {
// Chips still sitting in front of players on the current street are part of
// the pot as far as anyone at the table is concerned, so count them — waiting
// until the street closes would show a stale number for most of every hand.
const swept = HoldemState.totalPot(this.state);
const onStreet = this.state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
document.getElementById("potBadgeAmount").textContent = Money.format(swept + onStreet);
},
// ---- Seats around the table (compact pucks) ----
seatPosition(index, count) {
return this.positionOnRing(index, count, 44, 42);
},
// Bets sit on the felt between their player and the pot, the way chips actually
// do — closer in than the seats, on the same spoke. The bets layer is inset to
// match the seat ring (see the CSS), so this one radius holds at every size.
betPosition(index, count) {
return this.positionOnRing(index, count, 27, 25);
},
positionOnRing(index, count, rx, ry) {
const angle = -90 + (360 / count) * index;
const radians = (angle * Math.PI) / 180;
return {
left: 50 + rx * Math.cos(radians),
top: 50 + ry * Math.sin(radians),
};
},
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
// gets bets that look like the chips actually being pushed out.
chipColorFor(amount) {
const legend = [...(this.state.chipLegend || [])]
.filter(chip => Number(chip.value) > 0)
.sort((a, b) => Number(b.value) - Number(a.value));
const match = legend.find(chip => amount >= Number(chip.value));
return (match || legend[legend.length - 1] || {}).color || "#8a8a8a";
},
// ---- Bets on the felt ----
makeBetChip(bet) {
const chip = document.createElement("div");
chip.className = "betChip";
const disc = document.createElement("span");
disc.className = "betChipDisc";
disc.style.background = this.chipColorFor(bet);
const amount = document.createElement("span");
amount.className = "betChipAmount";
amount.textContent = Money.format(bet);
chip.appendChild(disc);
chip.appendChild(amount);
return chip;
},
// A phone's felt has no room for a third ring: measured every radius between the
// seats and the pot badge and each one collides with one or the other. So on a
// narrow screen the bet clings to its own puck instead — same chip, no ring —
// sitting on whichever side faces the middle of the table.
renderBets() {
const layer = document.getElementById("betsLayer");
if (!layer) return;
layer.innerHTML = "";
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
this.state.seats.forEach((player, index) => {
const bet = Betting.committed(player);
if (bet <= 0) return;
const chip = this.makeBetChip(bet);
const pos = this.betPosition(index, this.state.seatCount);
if (anchorToSeat) {
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (!seat) return;
const seatPos = this.seatPosition(index, this.state.seatCount);
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
seat.appendChild(chip);
return;
}
chip.style.left = `${pos.left}%`;
chip.style.top = `${pos.top}%`;
layer.appendChild(chip);
});
},
puckLabel(player, seatIndex) {
return HoldemState.displayName(player, seatIndex);
},
renderSeats() {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state);
this.state.seats.forEach((player, index) => {
const node = template.content.firstElementChild.cloneNode(true);
const pos = this.seatPosition(index, this.state.seatCount);
node.style.left = `${pos.left}%`;
node.style.top = `${pos.top}%`;
node.dataset.seatIndex = String(index);
node.querySelector(".seatChairNumber").textContent = index + 1;
const dealerChip = node.querySelector(".dealerChip");
dealerChip.classList.toggle("active", index === dealerIndex);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
dealerChip.addEventListener("click", e => {
e.stopPropagation();
this.state.dealerSeatIndex = index;
this.hooks.save(this.state);
this.renderSeats();
});
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
this.wireSeatGestures(node, index);
this.wireDragAndDrop(node, index);
layer.appendChild(node);
});
this.renderBets();
},
isRedSuit(card) {
return /[HD]$/i.test(card || "");
},
SUIT_SYMBOLS: { S: "♠", H: "♥", D: "♦", C: "♣" },
// "KD" -> "K♦". Stored cards stay two plain characters so every existing reader
// keeps working; the suit only becomes a symbol on its way to a human.
formatCard(card) {
const value = (card || "").toUpperCase();
if (!value) return "";
return `${value[0]}${this.SUIT_SYMBOLS[value[1]] || value[1] || ""}`;
},
// An empty slot has to look like something you can tap, so it gets a placeholder
// rather than going blank.
paintCardSlot(slot, card) {
const value = (card || "").toUpperCase();
slot.classList.toggle("cardRed", this.isRedSuit(value));
slot.classList.toggle("cardSlotEmpty", !value);
slot.textContent = value ? this.formatCard(value) : "+";
},
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
// Worth calling out, because at a short-handed table the open seats are what
// the dealer is looking for.
isEmptySeat(player) {
const named = !!(player.name && player.name.trim());
const contributing =
HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount) > 0;
return !named && !player.inHand && !contributing;
},
updatePuckContent(node, player, index) {
const empty = this.isEmptySeat(player);
const nameEl = node.querySelector(".seatPuckName");
nameEl.textContent = empty ? "Empty" : (player.name || "").trim();
nameEl.classList.toggle("seatEmptyLabel", empty);
const cardsEl = node.querySelector(".seatPuckCards");
cardsEl.innerHTML = "";
(player.cards || []).filter(Boolean).forEach((card, i) => {
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
const span = document.createElement("span");
if (this.isRedSuit(card)) span.className = "cardRed";
span.textContent = this.formatCard(card);
cardsEl.appendChild(span);
});
// Always this player's total for the hand. What they've just pushed out on this
// street is shown as a chip on the felt instead — the puck used to flip between
// the two meanings depending on the moment, which is a bad thing for a number
// sitting next to a stack size to do.
const handTotal = Betting.handTotal(player);
const total = handTotal > 0
? handTotal
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
const stackEl = node.querySelector(".seatPuckStack");
const stack = Betting.stackOf(this.state, player);
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
node.classList.toggle("seatEmpty", empty);
node.classList.toggle("seatInactive", !player.inHand && !empty);
node.classList.toggle("seatFolded", player.folded);
node.classList.toggle("seatLive", player.inHand && !player.folded);
},
// 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) {
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (node) this.updatePuckContent(node, this.state.seats[index], index);
},
// ---- Seat gestures ----
//
// One puck, three intents: tap to edit, double-tap to fold, press and hold for
// the quick menu. The single tap waits out the double-tap window before it
// fires — a fifth of a second of latency on opening a form, in exchange for a
// fold that doesn't also fling a sheet up over the table.
LONG_PRESS_MS: 500,
DOUBLE_TAP_MS: 220,
DRAG_SLOP_PX: 8,
pendingTap: null,
wireSeatGestures(node, index) {
let longPressTimer = null;
let longPressFired = false;
let startX = 0;
let startY = 0;
const cancelLongPress = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
node.addEventListener("pointerdown", e => {
longPressFired = false;
startX = e.clientX;
startY = e.clientY;
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
longPressFired = true;
this.cancelPendingTap();
node.classList.remove("seatPressing");
this.hooks.onSeatLongPress(index);
}, this.LONG_PRESS_MS);
node.classList.add("seatPressing");
});
// Moving means they're dragging the seat, not holding it.
node.addEventListener("pointermove", e => {
if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) {
cancelLongPress();
node.classList.remove("seatPressing");
}
});
["pointerup", "pointercancel", "pointerleave"].forEach(type => {
node.addEventListener(type, () => {
cancelLongPress();
node.classList.remove("seatPressing");
});
});
node.addEventListener("dragstart", () => {
cancelLongPress();
this.cancelPendingTap();
node.classList.remove("seatPressing");
});
node.addEventListener("click", e => {
// The long press already did something; don't also treat the release as a tap.
if (longPressFired) {
longPressFired = false;
e.preventDefault();
e.stopPropagation();
return;
}
this.handleSeatTap(index);
});
},
cancelPendingTap() {
if (this.pendingTap) {
clearTimeout(this.pendingTap.timer);
this.pendingTap = null;
}
},
handleSeatTap(index) {
if (this.pendingTap && this.pendingTap.index === index) {
this.cancelPendingTap();
this.hooks.onSeatDoubleTap(index);
return;
}
this.cancelPendingTap();
const timer = setTimeout(() => {
this.pendingTap = null;
this.toggleSeatEditor(index);
}, this.DOUBLE_TAP_MS);
this.pendingTap = { index, timer };
},
// Tapping the open seat again closes it — on a phone the editor is a sheet over
// the table, and the seat you just tapped is the closest thing to hand for
// dismissing it.
toggleSeatEditor(index) {
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
this.renderSeats();
this.renderSeatEditor();
},
wireDragAndDrop(node, index) {
node.addEventListener("dragstart", e => {
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
node.classList.add("dragging");
});
node.addEventListener("dragend", () => node.classList.remove("dragging"));
node.addEventListener("dragover", e => {
e.preventDefault();
node.classList.add("dropTarget");
});
node.addEventListener("dragleave", () => node.classList.remove("dropTarget"));
node.addEventListener("drop", e => {
e.preventDefault();
node.classList.remove("dropTarget");
const sourceIndex = Number(e.dataTransfer.getData("text/plain"));
const targetIndex = index;
if (Number.isNaN(sourceIndex) || sourceIndex === targetIndex) return;
const seats = this.state.seats;
[seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]];
this.hooks.save(this.state);
this.renderSeats();
if (this.selectedSeatIndex === sourceIndex || this.selectedSeatIndex === targetIndex) {
this.renderSeatEditor();
}
});
},
// ---- Seat editor panel (the full form for whichever seat is selected) ----
closeSeatEditor() {
this.selectedSeatIndex = null;
this.renderSeats();
this.renderSeatEditor();
},
renderSeatEditor() {
const title = document.getElementById("seatEditorTitle");
const body = document.getElementById("seatEditorBody");
// Drives the mobile bottom sheet: the panel only slides up over the table
// while a seat is actually being edited.
document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null);
if (this.selectedSeatIndex === null) {
title.textContent = "Select a seat";
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
return;
}
const index = this.selectedSeatIndex;
const player = this.state.seats[index];
title.textContent = this.puckLabel(player, index);
body.innerHTML = "";
const template = document.getElementById("seatEditorTemplate");
const form = template.content.firstElementChild.cloneNode(true);
this.wireSeatFields(form, player, index);
this.renderChipCounts(form, player, index);
this.renderCustomFields(form, player, index);
this.renderSeatTotal(form, player);
body.appendChild(form);
},
renderSeatTotal(form, player) {
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
form.querySelector(".seatTotalAmount").textContent = Money.format(total);
},
commitSeatChange(form, player, index) {
this.hooks.save(this.state);
this.updatePotBadge();
this.renderSeatTotal(form, player);
this.refreshPuck(index);
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
player.name = nameInput.value;
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
const inHandCheckbox = form.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
this.commitSeatChange(form, player, index);
});
const foldCheckboxRef = form.querySelector(".foldCheckbox");
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
foldCheckboxRef.addEventListener("change", () => {
player.folded = foldCheckboxRef.checked;
this.commitSeatChange(form, player, index);
});
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
slots.forEach((slot, cardIdx) => {
this.paintCardSlot(slot, player.cards[cardIdx]);
slot.addEventListener("click", () => {
CardPicker.open(this.state, {
onPick: (seatIndex, pickedIdx, card) => {
this.paintCardSlot(slots[pickedIdx], card);
this.commitSeatChange(form, player, seatIndex);
},
}, index, cardIdx);
});
});
// What they've handed over tonight, shown next to what's in front of them so a
// cash-out is one subtraction rather than a memory test.
const boughtIn = Number(player.buyIns) || 0;
const boughtInEl = form.querySelector(".seatBuyIns");
boughtInEl.textContent = boughtIn > 0 ? `Bought in ${Money.format(boughtIn)}` : "";
boughtInEl.hidden = boughtIn <= 0;
const stackInput = form.querySelector(".stackInput");
stackInput.value = player.stack ?? "";
stackInput.addEventListener("input", () => {
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
this.commitSeatChange(form, player, index);
});
const amountInput = form.querySelector(".amountOverrideInput");
amountInput.value = player.amountOverride ?? "";
amountInput.addEventListener("input", () => {
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
this.commitSeatChange(form, player, index);
});
const addFieldBtn = form.querySelector(".addFieldBtn");
addFieldBtn.addEventListener("click", () => {
player.customFields.push({ label: "", value: "" });
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
});
},
renderChipCounts(form, player, index) {
const container = form.querySelector(".chipCounts");
container.innerHTML = "";
const template = document.getElementById("chipCountTemplate");
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".chipSwatch").style.background = chip.color;
row.title = `$${chip.value} chips`;
const input = row.querySelector(".chipCountInput");
input.value = player.chipCounts[chip.id] || 0;
input.addEventListener("input", () => {
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
player.chipCounts[chip.id] = count;
this.commitSeatChange(form, player, index);
});
container.appendChild(row);
});
},
renderCustomFields(form, player, index) {
const container = form.querySelector(".customFields");
container.innerHTML = "";
const template = document.getElementById("customFieldTemplate");
player.customFields.forEach((field, fieldIdx) => {
const row = template.content.firstElementChild.cloneNode(true);
const labelInput = row.querySelector(".customFieldLabel");
const valueInput = row.querySelector(".customFieldValue");
labelInput.value = field.label;
valueInput.value = field.value;
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
this.hooks.save(this.state);
});
valueInput.addEventListener("input", () => {
field.value = valueInput.value;
this.hooks.save(this.state);
});
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
});
container.appendChild(row);
});
},
// ---- Chip legend ----
renderChipLegend() {
const list = document.getElementById("chipLegendList");
list.innerHTML = "";
const template = document.getElementById("chipLegendRowTemplate");
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
const colorInput = row.querySelector(".chipColorInput");
const valueInput = row.querySelector(".chipValueInput");
colorInput.value = chip.color;
valueInput.value = chip.value;
colorInput.addEventListener("input", () => {
chip.color = colorInput.value;
this.hooks.save(this.state);
});
valueInput.addEventListener("input", () => {
chip.value = Number(valueInput.value) || 0;
this.hooks.save(this.state);
this.renderSeats();
this.renderSeatEditor();
this.updatePotBadge();
});
row.querySelector(".removeChipColorBtn").addEventListener("click", () => {
this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
this.hooks.save(this.state);
this.renderAll();
});
list.appendChild(row);
});
},
// ---- Pot breakdown ----
// Shared by the action bar (live, mid-hand) and the pot wizard (before winners are
// picked), because both are showing the dealer the same announcement.
buildPotBreakdown(pots, returns, nameFor, heading) {
if (pots.length < 2 && returns.length === 0) return null;
const wrap = document.createElement("div");
wrap.className = "potBreakdown";
if (heading) {
const title = document.createElement("p");
title.className = "potBreakdownHeading";
title.textContent = heading;
wrap.appendChild(title);
}
const addRow = (label, amount, who, className) => {
const row = document.createElement("div");
row.className = `potLayer ${className || ""}`;
const labelEl = document.createElement("span");
labelEl.className = "potLayerLabel";
labelEl.textContent = label;
const amountEl = document.createElement("span");
amountEl.className = "potLayerAmount";
amountEl.textContent = Money.format(amount);
const whoEl = document.createElement("span");
whoEl.className = "potLayerWho";
whoEl.textContent = who;
row.appendChild(labelEl);
row.appendChild(amountEl);
row.appendChild(whoEl);
wrap.appendChild(row);
};
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", ")));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
return wrap;
},
// ---- Session standings ----
renderStandings() {
const list = document.getElementById("standingsList");
const emptyHint = document.getElementById("standingsEmptyHint");
const table = document.getElementById("standingsTable");
const standings = HoldemState.sessionStandings(this.state);
list.innerHTML = "";
emptyHint.hidden = standings.length > 0;
table.hidden = standings.length === 0;
const template = document.getElementById("standingsRowTemplate");
standings.forEach(row => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".standingsName").textContent = row.name;
node.querySelector(".standingsIn").textContent = Money.format(row.in);
node.querySelector(".standingsWon").textContent = Money.format(row.won);
const netEl = node.querySelector(".standingsNet");
netEl.textContent = Money.formatSigned(row.net);
netEl.classList.toggle("netUp", row.net > 0);
netEl.classList.toggle("netDown", row.net < 0);
list.appendChild(node);
});
},
// ---- Hand counter + history ----
formatHandTime(timestamp) {
try {
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
} catch {
return "";
}
},
renderHandHistory() {
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
const list = document.getElementById("handHistoryList");
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
list.innerHTML = "";
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
const template = document.getElementById("handHistoryRowTemplate");
history.forEach(entry => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
row.querySelector(".handHistoryTime").textContent = this.formatHandTime(entry.timestamp);
const payoutsEl = row.querySelector(".handHistoryPayouts");
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
list.appendChild(row);
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Render };
}
@@ -0,0 +1,81 @@
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
// one press and one tap away, without opening the full editor and without hunting
// for whose turn it is in the action bar.
const SeatMenu = {
state: null,
hooks: null,
seatIndex: null,
open(state, hooks, seatIndex) {
this.state = state;
this.hooks = hooks;
this.seatIndex = seatIndex;
this.render();
document.getElementById("seatMenuOverlay").hidden = false;
},
close() {
document.getElementById("seatMenuOverlay").hidden = true;
},
// Only the actions that make sense for this seat right now — a folded player has
// nothing to go all-in with, and an empty chair has nothing to fold.
actionsFor(player, index) {
const actions = [];
const dealtIn = player.inHand;
const live = dealtIn && !player.folded;
if (live) {
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
} else if (dealtIn && player.folded) {
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
}
// The most common interruption in a cash game, and until now it meant opening
// the editor and retyping a stack from memory.
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(index) });
actions.push({
label: dealtIn ? "Sit out" : "Deal in",
run: () => this.hooks.toggleDealtIn(index),
});
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
render() {
const player = this.state.seats[this.seatIndex];
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player);
const bits = [];
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
this.actionsFor(player, this.seatIndex).forEach(action => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `seatMenuBtn ${action.className || ""}`;
btn.textContent = action.label;
btn.addEventListener("click", () => {
this.close();
action.run();
});
list.appendChild(btn);
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SeatMenu;
}
@@ -0,0 +1,64 @@
// Keyboard control for a dealer running this on a laptop.
//
// The phone has gestures; the desktop had a mouse and nothing else. These are the
// same actions the bar already offers, reachable without moving a hand to the
// trackpad between every decision — which is the whole point when the bar is being
// tapped thirty times a hand.
const Shortcuts = {
// Deliberately single letters with no modifier: a dealer's spare hand rests on
// the keyboard, and a chord is slower than reaching for the button would be.
KEYS: {
f: "fold",
c: "checkOrCall",
r: "raise",
a: "allIn",
" ": "advance",
enter: "advance",
u: "undo",
escape: "cancel",
},
mount(handlers) {
this.handlers = handlers;
document.addEventListener("keydown", e => this.onKey(e));
},
// Nothing fires while the dealer is typing a name, an amount, or a note — the
// "c" in "Cass" must not fold the table.
isTyping() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
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.
overlayOpen() {
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
.some(id => {
const el = document.getElementById(id);
return el && !el.hidden;
});
},
onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
const action = this.KEYS[key];
if (!action) return;
// Escape is the exception: it should still close the raise pad even if focus
// happens to be sitting in its amount field.
if (this.isTyping() && !(action === "cancel")) return;
if (this.overlayOpen()) return;
const handled = this.handlers[action] && this.handlers[action]();
if (handled) e.preventDefault();
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = Shortcuts;
}
@@ -0,0 +1,53 @@
// Pure math for chopping one pot between tied players. No DOM.
//
// A dealer can't push half a chip across the felt, so this works in whole chips of
// whatever the smallest denomination in play is: everyone gets the same stack, and
// whatever won't divide is called out separately as odd chips. House rule for those
// is that they go to the first player left of the button, one at a time.
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);
},
split(potAmount, ways, chipAmount) {
const potCents = this.toCents(potAmount);
const chipCents = Math.max(1, this.toCents(chipAmount));
const players = Math.max(1, Math.floor(Number(ways) || 0));
if (potCents <= 0) {
return { valid: false, each: 0, oddChips: 0, chip: chipCents / 100, dust: 0, players };
}
const wholeChips = Math.floor(potCents / chipCents);
// Left over when the pot itself isn't a whole number of the smallest chip —
// usually means the dealer picked the wrong denomination, so it's surfaced
// rather than silently rolled into someone's stack.
const dustCents = potCents - wholeChips * chipCents;
const chipsEach = Math.floor(wholeChips / players);
const oddChips = wholeChips - chipsEach * players;
return {
valid: true,
players,
each: (chipsEach * chipCents) / 100,
oddChips,
chip: chipCents / 100,
dust: dustCents / 100,
};
},
// 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;
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SplitCalculator;
}
@@ -0,0 +1,287 @@
// Table state: seats, chips, blinds, notes. Persisted to localStorage so a dealer's
// browser refresh mid-game doesn't lose the table.
const HoldemState = {
STORAGE_KEY: "casino.holdem.state.v1",
DEFAULT_SEAT_COUNT: 10,
DEFAULT_CHIP_LEGEND: [
{ id: "white", color: "#f5f5f5", value: 1 },
{ id: "red", color: "#c0392b", value: 5 },
{ id: "green", color: "#1e8449", value: 25 },
{ id: "black", color: "#111111", value: 100 },
{ id: "purple", color: "#6c3483", value: 500 },
],
makeId(prefix) {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
},
makePlayer() {
return {
id: this.makeId("player"),
name: "",
inHand: false,
folded: false,
cards: ["", ""],
chipCounts: {},
amountOverride: null,
customFields: [],
// Live-betting fields: chips in front of the player, chips they've put in
// on the current street, and whether they've answered the current bet.
stack: null,
committed: 0,
hasActed: false,
// Cash handed over tonight, which only moves when they buy chips.
buyIns: 0,
};
},
MAX_HISTORY_ENTRIES: 200,
createDefault() {
const seatCount = this.DEFAULT_SEAT_COUNT;
return {
seatCount,
direction: "clockwise",
basePotAmount: null,
dealerSeatIndex: 0,
dedicatedDealer: false,
chipLegend: this.DEFAULT_CHIP_LEGEND.map(c => ({ ...c })),
seats: Array.from({ length: seatCount }, () => this.makePlayer()),
notes: "",
handNumber: 1,
handHistory: [],
smallBlind: 1,
bigBlind: 2,
defaultStack: null,
street: "preflop",
currentBet: 0,
lastRaiseSize: 0,
actingSeatIndex: null,
};
},
load() {
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
// 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"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
parsed.seats.forEach(player => {
if (player.stack === undefined) player.stack = null;
if (player.committed === undefined) player.committed = 0;
if (player.hasActed === undefined) player.hasActed = false;
if (player.buyIns === undefined) player.buyIns = 0;
});
return parsed;
} catch {
return this.createDefault();
}
},
save(state) {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage full/unavailable (private browsing, etc.) — the table still works
// for the rest of the session, it just won't survive a refresh.
}
},
clear() {
try {
localStorage.removeItem(this.STORAGE_KEY);
} catch {
// ignore
}
},
resizeSeats(state, newCount) {
const count = Math.max(2, Math.min(24, Math.floor(newCount) || this.DEFAULT_SEAT_COUNT));
const seats = state.seats.slice(0, count);
while (seats.length < count) seats.push(this.makePlayer());
state.seats = seats;
state.seatCount = count;
if (state.dealerSeatIndex >= count) state.dealerSeatIndex = 0;
return state;
},
// What to call a seat in logs, payouts and standings when the dealer hasn't
// typed a name in yet.
displayName(player, seatIndex) {
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
},
// Contribution the table calculator will treat this player as having put in.
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);
if (!Number.isNaN(parsed)) return Math.max(0, parsed);
}
const chipTotal = chipLegend.reduce((sum, chip) => {
const count = Number(player.chipCounts[chip.id]) || 0;
return sum + count * Number(chip.value || 0);
}, 0);
if (chipTotal > 0) return chipTotal;
const base = Number(basePotAmount);
return Number.isNaN(base) ? 0 : Math.max(0, base);
},
totalPot(state) {
return state.seats.reduce(
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
0
);
},
// Next occupied-by-index seat in the current blind direction. Every seat index
// counts (there's no separate "empty chair" concept) — a dealer running fewer
// players just keeps the seat count matched to who's actually sitting down.
nextSeatIndex(state, fromIndex, step = 1) {
const count = state.seatCount;
const delta = state.direction === "counterclockwise" ? -step : step;
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);
},
getBlindSeats(state) {
const sbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
const bbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 2);
return { dealerIndex: state.dealerSeatIndex, sbIndex, bbIndex };
},
startNextHand(state) {
this.advanceButton(state);
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
p.chipCounts = {};
p.amountOverride = null;
p.committed = 0;
p.hasActed = false;
});
},
// Appends a resolved-pot record to the running session log a dealer can point to
// for chip-count disputes, then advances the hand counter. `payouts` is
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
//
// Must be called before startNextHand() clears the seats: the per-player `ledger`
// it stores alongside the payouts is what session standings are summed from later,
// and it needs this hand's contributions while they're still on the table.
recordHand(state, payouts) {
if (typeof state.handNumber !== "number") state.handNumber = 1;
if (!Array.isArray(state.handHistory)) state.handHistory = [];
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
const ledger = state.seats
.map((player, index) => ({
id: player.id,
name: this.displayName(player, index),
in: this.computeContribution(player, state.chipLegend, state.basePotAmount),
won: wonById.get(player.id) || 0,
}))
.filter(row => row.in > 0 || row.won > 0);
state.handHistory.unshift({
handNumber: state.handNumber,
timestamp: Date.now(),
pot,
payouts,
ledger,
});
if (state.handHistory.length > this.MAX_HISTORY_ENTRIES) {
state.handHistory.length = this.MAX_HISTORY_ENTRIES;
}
state.handNumber += 1;
},
clearHistory(state) {
state.handHistory = [];
state.handNumber = 1;
},
// Running per-player totals for the whole session, so a dealer settling up at the
// end of the night reads one number per person instead of adding up every hand.
// Keyed by seat id, which survives renames and reseating; hands recorded before
// the ledger existed simply don't contribute to the totals.
sessionStandings(state) {
const totals = new Map();
(state.handHistory || []).forEach(entry => {
(entry.ledger || []).forEach(row => {
// History is newest-first, so the first name seen for an id is the
// most recent one the dealer typed.
const running = totals.get(row.id) || { id: row.id, name: row.name, in: 0, won: 0 };
running.in += row.in;
running.won += row.won;
totals.set(row.id, running);
});
});
// Cash bought in and chips currently in front don't come from the hand log at
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
const seatById = new Map(state.seats.map(p => [p.id, p]));
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;
// 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 —
// and blinds are posted the instant the previous one settles, so that
// window is almost always.
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
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,
boughtIn,
chipsNow,
cashPosition: Math.round((chipsNow + inFrontOfThem - boughtIn) * 100) / 100,
};
})
.sort((a, b) => b.net - a.net);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HoldemState;
}
+7 -3
View File
@@ -103,9 +103,13 @@ CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle trainer);
/* Record one played position (post-move FEN) into the accumulator. */
CHESS_API void CHESS_CALL trainer_record(TrainerHandle trainer, const char* fen);
/* Apply a finished game's outcome to the global weights and save: rewards the winner's
* occupied squares / features and punishes the loser's, scaled by `weight` (e.g. 0.5 for a
* material-imbalance draw). winner: 0 = white, 1 = black. */
/* Apply a finished game's outcome to the global weights and save. Each touched cell (a piece
* on a square, or a feature) tracks a presence-weighted running average of the game's
* "advantage" — the game's outcome (scaled by `weight`, e.g. 0.5 for a material-imbalance
* draw) minus how much of that outcome the winner's average material edge already explains —
* so mg/eg/featW end up as a fractional win-rate (percent of games won with that piece there,
* minus percent lost), not an unbounded plus/minus tally, and a piece doesn't get credit
* merely for existing on the winning side of a material blowout. winner: 0 = white, 1 = black. */
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight);
#ifdef __cplusplus
+122 -30
View File
@@ -26,6 +26,7 @@
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iomanip>
#include <mutex>
#include <new>
#include <string>
@@ -115,11 +116,25 @@ struct ChessEngine {
/* The process-global learned weights: the single source of truth, loaded from disk once and
* updated in place by training. Engine handles snapshot it at creation; the visualization
* snapshots it on demand. Guarded by g_weightsMutex for updates/saves (eval reads its own
* per-handle copy, so it never touches this concurrently). */
* per-handle copy, so it never touches this concurrently).
*
* mg/eg/featW are DERIVED, not accumulated: each is always exactly OUT_SCALE * (score/visits)
* for its cell (see trainer_apply), recomputed whenever that cell's stats change. visits is
* the presence-weighted count of games that touched the cell (how much to trust the average);
* score is the presence-weighted sum of each game's material-adjusted advantage (see
* trainer_apply) — so score/visits is literally "fraction of (presence-weighted) games won
* with this piece here, minus fraction lost," bounded to [-1,1] by construction. */
struct LearnedWeights {
int mg[chess::PIECE_TYPE_NB][64] = {};
int eg[chess::PIECE_TYPE_NB][64] = {};
int featW[FEATURE_NB] = {};
double mgVisits[chess::PIECE_TYPE_NB][64] = {};
double mgScore [chess::PIECE_TYPE_NB][64] = {};
double egVisits[chess::PIECE_TYPE_NB][64] = {};
double egScore [chess::PIECE_TYPE_NB][64] = {};
double featVisits[FEATURE_NB] = {};
double featScore [FEATURE_NB] = {};
};
static LearnedWeights g_weights;
@@ -127,13 +142,15 @@ static std::mutex g_weightsMutex;
static std::string g_weightsPath;
/* Per-game training accumulator (one per learned CPU-vs-CPU game). Records, per ply, where
* each side's pieces sat (split into midgame/endgame by phase) and each side's feature
* activations; trainer_apply turns the totals into weight nudges. Squares are white-relative
* each side's pieces sat (split into midgame/endgame by phase), each side's feature
* activations, and the white-minus-black material balance; trainer_apply turns the totals
* into a presence-weighted update to the running per-cell averages. Squares are white-relative
* (black indexes sq ^ 56), so a side's tally lines up with the shared white-relative table. */
struct Trainer {
double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
double featAcc[chess::COLOR_NB][FEATURE_NB] = {};
double matDiffAcc = 0.0; /* sum over plies of (white material - black material), cp */
int plies = 0;
};
@@ -172,9 +189,12 @@ static int parse_variant(const char* options) {
}
/* On-disk format: 6*64 mg ints (PAWN..KING, squares 0..63), then 6*64 eg ints, then
* FEATURE_NB feature ints, whitespace-separated. A missing file or short read leaves the
* rest neutral (0), so an absent weights file just means "train from a blank slate".
* Caller holds g_weightsMutex. */
* FEATURE_NB feature ints, then the backing visits/score doubles for each (mgVisits, mgScore,
* egVisits, egScore, featVisits, featScore) in the same shapes, all whitespace-separated. A
* missing file or short read leaves the rest neutral (0), so an absent weights file just means
* "train from a blank slate" — and a file written by the pre-fractional-learning format (which
* ends after the feature ints) loads its mg/eg/featW fine and simply starts every cell's
* visits/score history fresh from the next game that touches it. Caller holds g_weightsMutex. */
static void load_global_weights(const char* path) {
g_weights = LearnedWeights{}; /* reset to neutral before loading */
@@ -190,9 +210,29 @@ static void load_global_weights(const char* path) {
if (!(f >> g_weights.eg[pt][sq])) return;
for (int i = 0; i < FEATURE_NB; ++i)
if (!(f >> g_weights.featW[i])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.mgVisits[pt][sq])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.mgScore[pt][sq])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.egVisits[pt][sq])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.egScore[pt][sq])) return;
for (int i = 0; i < FEATURE_NB; ++i)
if (!(f >> g_weights.featVisits[i])) return;
for (int i = 0; i < FEATURE_NB; ++i)
if (!(f >> g_weights.featScore[i])) return;
}
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Caller holds the lock. */
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Full round-trip
* precision on the doubles (visits/score are running sums that keep accumulating across many
* games, so truncating them would drift the derived mg/eg/featW over time). Caller holds the
* lock. */
static void save_global_weights() {
if (g_weightsPath.empty()) return;
std::ofstream f(g_weightsPath);
@@ -203,6 +243,18 @@ static void save_global_weights() {
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.eg[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featW[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
f << std::setprecision(17);
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgScore[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.egVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.egScore[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featVisits[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featScore[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
}
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no
@@ -382,13 +434,18 @@ static int evaluate(const chess::Position& pos) {
* compute_features() is the single source of feature activations, used by BOTH the eval here
* and the trainer, so the two can never disagree. Constants below are the only tunables. */
/* Per-game-outcome learning rates and clamps. Squares accumulate occupancy (plies on a
* square, summed); features accumulate normalized per-ply activation (averaged, divided by a
* nominal scale so high-magnitude mobility doesn't dwarf the small pawn-structure terms). */
static constexpr double SQUARE_LR = 0.5;
static constexpr int SQ_CLAMP = 250;
static constexpr double FEAT_LR = 2.0;
static constexpr int FEAT_CLAMP = 500;
/* Per-game-outcome learning constants. Squares and features both learn a presence/activation-
* weighted AVERAGE advantage in [-1,1] (see trainer_apply) — OUT_SCALE just converts that
* average into a centipawn-ish int for the eval/table to add directly. FEAT_SCALE normalizes
* each feature's raw per-ply activation to a comparable magnitude before it's used as a weight
* (so high-count mobility doesn't dwarf the small pawn-structure terms), same as before.
* MATERIAL_ELO_SCALE is the classic centipawn-to-win-probability curve
* (expected = 1/(1+10^(-cp/400))) used to compute how much of a result material already
* explains, so training credits placement/features only for the part of the result that
* material didn't already predict. */
static constexpr int SQ_OUT_SCALE = 250;
static constexpr int FEAT_OUT_SCALE = 500;
static constexpr double MATERIAL_ELO_SCALE = 400.0;
static constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 1, 1, 2 };
/* Game phase in [0,1] from remaining non-pawn material (PeSTO weights N=B=1, R=2, Q=4; max
@@ -823,7 +880,9 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
chess::Position pos = chess::Position::from_fen(fen);
double phase = game_phase(pos);
/* Per-square occupancy, split into midgame/endgame by phase, white-relative. */
/* Per-square occupancy, split into midgame/endgame by phase, white-relative; and the
* white-minus-black material balance at this ply, used at trainer_apply time to strip
* "material already explains this" out of the training signal. */
chess::Bitboard occ = pos.pieces();
while (occ) {
chess::Square s = chess::pop_lsb(occ);
@@ -833,6 +892,7 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56);
t->mgOcc[c][pt][relSq] += (1.0 - phase);
t->egOcc[c][pt][relSq] += phase;
t->matDiffAcc += (c == chess::WHITE ? 1 : -1) * piece_value(pt);
}
/* Per-side feature activations. */
@@ -847,34 +907,66 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
t->plies++;
}
/* Applies a finished game's outcome as a presence-weighted running average per cell, instead
* of an unbounded incremental nudge:
*
* score[cell] / visits[cell] == "fraction of (presence-weighted) games won with this piece
* on this square, minus fraction lost"
*
* `weight` is the caller's confidence in the result (1.0 for a checkmate, 0.5 for a
* material-imbalance draw, as before). On top of that, the update is scaled by how much of the
* result ISN'T already explained by material: `expected` is the win probability the winner's
* average material edge over the game implies (the same centipawn->win-probability curve
* engines use for Elo estimates); `1 - expected` is the "surprise" left over once material is
* accounted for. A win where the winner was already up a rook the whole game is unsurprising
* (expected ~= 1, ~no update) — the eval already knows material matters via piece_value(), so
* square/feature tables shouldn't get credit for "a piece existed"; a win pulled off (or a draw
* held) while materially behind is highly informative and gets a correspondingly large update.
* This same magnitude applies to BOTH the winner's reward and the loser's punishment: by the
* symmetry of win probabilities (P(loser wins) = 1 - P(winner wins)), the loser's own advantage
* — actual(0) minus their expected win probability — has exactly the same magnitude, `1 -
* expected`, just negative. */
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) {
if (!t) return;
if (!t || t->plies <= 0) return;
double avgMaterialDiff = t->matDiffAcc / t->plies; /* white - black, cp */
double winnerMaterialDiff = (winner == chess::WHITE) ? avgMaterialDiff : -avgMaterialDiff;
double expected = 1.0 / (1.0 + std::pow(10.0, -winnerMaterialDiff / MATERIAL_ELO_SCALE));
double advantageMag = weight * (1.0 - expected);
std::lock_guard<std::mutex> lock(g_weightsMutex);
/* pass 0 = winner (reward, +1); pass 1 = loser (punish, -1). */
/* pass 0 = winner (positive advantage); pass 1 = loser (negative advantage). */
for (int pass = 0; pass < 2; ++pass) {
chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1);
int sign = pass == 0 ? 1 : -1;
double advantage = pass == 0 ? advantageMag : -advantageMag;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) {
if (t->mgOcc[side][pt][sq] != 0.0) {
int d = sign * int(std::lround(SQUARE_LR * t->mgOcc[side][pt][sq] * weight));
g_weights.mg[pt][sq] = std::clamp(g_weights.mg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
double mgFrac = t->mgOcc[side][pt][sq] / t->plies; /* fraction of the game spent here, [0,1] */
if (mgFrac != 0.0) {
g_weights.mgVisits[pt][sq] += mgFrac;
g_weights.mgScore [pt][sq] += mgFrac * advantage;
g_weights.mg[pt][sq] = int(std::lround(
SQ_OUT_SCALE * g_weights.mgScore[pt][sq] / g_weights.mgVisits[pt][sq]));
}
if (t->egOcc[side][pt][sq] != 0.0) {
int d = sign * int(std::lround(SQUARE_LR * t->egOcc[side][pt][sq] * weight));
g_weights.eg[pt][sq] = std::clamp(g_weights.eg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
double egFrac = t->egOcc[side][pt][sq] / t->plies;
if (egFrac != 0.0) {
g_weights.egVisits[pt][sq] += egFrac;
g_weights.egScore [pt][sq] += egFrac * advantage;
g_weights.eg[pt][sq] = int(std::lround(
SQ_OUT_SCALE * g_weights.egScore[pt][sq] / g_weights.egVisits[pt][sq]));
}
}
if (t->plies > 0)
for (int i = 0; i < FEATURE_NB; ++i) {
double avg = t->featAcc[side][i] / t->plies; /* per-ply average, normalized */
int d = sign * int(std::lround(FEAT_LR * (avg / FEAT_SCALE[i]) * weight));
g_weights.featW[i] = std::clamp(g_weights.featW[i] + d, -FEAT_CLAMP, FEAT_CLAMP);
}
for (int i = 0; i < FEATURE_NB; ++i) {
double activation = (t->featAcc[side][i] / t->plies) / FEAT_SCALE[i]; /* per-ply average, normalized */
if (activation == 0.0) continue;
g_weights.featVisits[i] += std::abs(activation);
g_weights.featScore [i] += activation * advantage;
g_weights.featW[i] = int(std::lround(
FEAT_OUT_SCALE * g_weights.featScore[i] / g_weights.featVisits[i]));
}
}
save_global_weights();