polish/holdem-seats-one-size
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6992833821 |
Make the way back reachable, and stop it leaving while it is being reached for
This tool prefers undo to confirmation, deliberately — runUndoable says so in as many words — which makes the toast's Undo the safety net under the biggest things it does. It was not one. Every whole-step undo clears the per-action history on its way past, so the bar has no pill left and the toast's button is the only way back. Measured on a desktop it is the 67th of the page's 68 tab stops, and it is gone in eight seconds. And U — the key the sidebar legend advertises as taking back the last action — found nothing and did nothing: it only ever looked in the bar. So U looks at the toast first, by pressing the button that is on the screen rather than running a second copy of what it does, and only at a button that named the shortcut: "Turn off" on the wake lock and "Reload" on the other-tab warning are not things a stray keypress should reach. The eight seconds also ran whatever the dealer was doing. A cursor resting on the toast did not stop it; nor did tabbing onto the button. Measured before: focus the Undo, wait 8.6 seconds, and the toast is gone and focus has been handed to <body> — the offer withdrawn and the dealer's place in the page with it. Touching it or tabbing into it now stops the clock, and letting go starts it again with at least the time to act rather than whatever was left of the time to notice: let go at 7.9 seconds of 8 and 100ms is not an offer. Dismissing with focus inside puts focus back where the dealer had got to, which is read off where focus crossed into the toast rather than off where it was when the message arrived. And each toast carried its own role="status" inside #toastHost, which is already a live region in the markup. A region nested in a region, announced twice; the one in the markup is the one that works, because it is the one anything has been watching. 1342 tests pass, 18 of them new — and 8 of those fail against the old code. The sweep is clean across 428 audits; contrast unchanged. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb |
||
|
|
c1d0105d12 |
Answer the press instead of eating it
The card picker greys out a card that is already dealt and hangs the reason off a `title`. Measured on an iPhone with three hands out: seven of the fifty-two were dead, none of them could take focus, and tapping one changed nothing at all — no toast, no text, not a byte of DOM. The tool's answer to "who has the ace of spades?" existed and was reachable only with a mouse. The header's two buttons were the same, on a new dealer's first screen, and Export CSV was greyed out with no explanation written anywhere. `disabled` is the wrong attribute whenever there is something to say: it drops the control out of the tab order, so a screen reader never lands on it to hear the description, and it eats the tap on a touch screen, where the hover that would have shown the `title` does not exist. `aria-disabled` announces the same state and leaves the control reachable, so one capture listener can turn the press into a sentence — in a toast, or inside a sheet that is standing on top of the toast. It paid for itself in the picker's keyboard too. The arrows hunted past dealt cards because a `disabled` button cannot hold focus, and the hunting did the exact thing the grid's no-wrap rule was written to prevent: Right from A-spades over a dealt A-hearts landed on A-diamonds, so the column shifted and the next press of Down walked a different suit. A cell that can hold focus needs no hunting, and the APG asks for that inside a composite widget anyway. The line that answers is one line by measurement, not by taste. Its box is 318px on a Galaxy S8 at 20px of root text; the first wording drew 332px and wrapped, shoving fifty-two targets 21px down mid-tap, and twenty-four capital Ws draw 434px whatever the wording, so the box is what holds. Two things the sweep found on the way. It opened the card picker by un-hiding an overlay whose grid had never been painted, so for its whole life it audited an empty sheet; it now opens it the way a dealer does, with cards out and the answer showing. And the slot buttons above the grid were named to a screen reader by the em dash they draw when empty. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb |
||
|
|
2d80edd513 |
Say how many ways once, in the one control that owns it
"Split between" on Quick Split was two controls for one value stacked on each other: chips 2 3 4 5, and under them a bar reading "2 ways" with a − and a +. The count was written down three times at once — the lit chip, the bar, and "Each of 2 players gets" in the panel above — and two of the three were controls. 134px with its label, against 74px for "Smallest chip in play" right below it, which offers five choices in one row. The bar was never meant to be a bar. `.waysStepper` asked for a 150px min-width inside a wrapping flex row, so on a phone it always wrapped to a line of its own, and `flex: 1 1 auto` then gave it all 358px of that line: 252px of empty readout with the − and + pushed 304px apart, on a screen 390px wide. Now it is 136px with its ends 121px apart, and it only exists once you are through the door — a fifth chip reading "6+", behind which the stepper is where the count is written. Under six ways, which is every chop a home game will run, the field is one row and 74px. Two more things went with it: Neither stepper ever disabled an end, so − at the floor and + at the ceiling looked live and swallowed the tap. NN/G's stepper guidance asks for the opposite. Both ends of both steppers now grey out. The per-pot chop capped its + with `Math.max(contenderCount, MAX_WAYS)`, which is always 12 — a side pot only P1 and P2 were eligible for would happily chop ten ways at $12 each. It is `chopCeiling()` now, and a two-contender pot is offered no stepper at all, that being the common shape. The sweep went straight to the all-in mode and audited only that, so the mode the screen opens in had never been looked at in fourteen rounds, which is how a 358px bar lasted this long. It audits both now — 428 audits, clean — and the first run of it found the step buttons had been 40px wide rather than 44 since they were written. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb |
||
|
|
49c2d745eb |
Let the keyboard into the sheets, and stop dropping it on the floor
Modal answers one question for all six overlays — where focus goes and how Tab is held — and this round it was measured as wrong in three directions. Its list of Tab stops could not see a <summary>. The rulings sheet is twelve of them and one Done button, so the trap decided Done was both ends of the ring: eighty Tab presses, one element ever reached, twelve rulings that could not be opened without a mouse. Adding `details > summary` gives the sheet its thirteen stops back. `:not([tabindex="-1"])` moves onto every line while it is open, because a control taken out of the Tab order still matches `button`. "The first control" was the card picker's Clear: the sheet a dealer opens to write a card down opened on the button that rubs one out. A sheet names its own landing spot now, and on five of six it is the heading — which says which seat, which card, which pot. The confirm dialog keeps a control, because a short question with one irreversible answer should land on the answer that leaves the dealer no worse off. And a sheet that redraws itself was deleting the control that held focus. Choosing a buy-in, picking a card, switching slots, confirming a side pot — all four left focus on the document body. Rebuilt controls carry a name now and are handed back to whoever had them; where there is no coming back, the caller says where to go instead. The card grid was fifty-two Tab stops of the fifty-six in that sheet. It is one now, with the arrows moving inside it, stepping over cards already dealt and stopping at the edges rather than wrapping onto a different rank. 1136 tests, 418 audits clean. |
||
|
|
9af65a7b82 |
Sort a seat's eleven actions by when, not draw them all the same
The long-press menu had grown to eleven actions and drew every one of them as the same full-width bar. On the fullest menu the tool can build — a live seat that isn't the dealer, blinds still the only money in, an override already set — that came to 796px and took 85% of the screen on all four viewports measured. It scrolled on all four, and Cancel was off the bottom of the screen on all four: 79px past it on a 390px phone, 252px on a 360px one, 448px in landscape. Apple puts the comfortable number for an action sheet at three to five and says not to let one scroll at all. Nothing is cut. Every one of the eleven is the only route to something, and "Blinds follow the button again" is the only route in the whole tool to clearing a blind override. They are sorted by *when* instead: a seat action is either something you do with cards in the air or housekeeping between hands, and those two want different targets. The hand keeps the full-width bars — Fold, All-in, Un-fold, and seating the next player off the rail, because a chair opens between two hands with people watching it. Everything else goes to a two-column grid of quieter 44px tiles underneath. The captions are not decoration. A half-width tile reading "Small blind" is a label, and the puck already wears one of those; under "Give this seat" it is a verb again. That is what lets three of the longest labels here lose ten characters each and still fit a half tile on one line, which is the height the caption pays back. The override reset spans the grid rather than sitting in it as a fourth position to give, dashed and unfilled — the felt's own word for "nothing set by hand here" — so it doesn't read as the Cancel below it either. Same fullest menu afterwards: 650px, 77%, no scroll on a phone or a desktop. What a dealer actually sees mid-hand is eight actions and 546px, 65%. The move picker, which was eleven more full-width bars, now fits all eleven chairs of a twelve-handed table on a 360px phone without scrolling. Two things the height was hiding, fixed with it: The header is stuck to the top of the sheet's scrollport and carries a real close button. NN/g is explicit that a grab handle is not a close affordance, and this is the sheet that can still outgrow a landscape phone — so the way out, and whose menu this is, are now in the part that cannot scroll away. That close button is also the first control in the markup, which is what Modal focuses. It used to focus Fold. The APG says to keep a dialog's initial focus off the destructive choice; a close button at the top puts it there without a special case. Escape finds that one rather than the button at the foot, so Escape closes the sheet while the foot steps back — and it now has somewhere to step back to: opening the move picker used to replace the sheet and leave only a control that threw the whole menu away. 1068 tests pass; the 418-audit sweep is clean. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb |
||
|
|
e2bb935b43 |
Be there when the signal, the screen and the storage are not
Three failures that only ever happen at a table, none of which show up on a desk with a good connection and a plugged-in laptop. The offline promise depended on how the URL was spelled. Razor Pages routing is case-insensitive, so /Casino/TexasHoldem serves the identical page; a service worker's scope is matched as a plain string prefix, so a worker scoped to /casino/ does not control it. The registration succeeded, the worker activated, nothing was logged, and the cache stayed completely empty -- because the precache list was handed to navigator.serviceWorker.ready, which resolves with the registration controlling the page and so never resolves at all outside its scope. A dealer who opened that URL to load the tool for the night had loaded nothing. One spelling wins now, via a 301 scoped to /casino rather than applied site-wide: every file under wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing there cannot miss a real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the site, and /chessHub would break. The handshake is fixed rather than left to be saved by the redirect -- it takes the worker off the registration itself. The manifest icons were never cached, because the shell list is read off the DOM and the DOM never mentions them. They are read out of the manifest now, cache-first so an offline launch stops logging ERR_INTERNET_DISCONNECTED, and the link sweep picks up apple-touch-icon, which is the only one iOS reads. The screen slept mid-hand. A wake lock is held while betting is open and dropped the moment it is not: through a whole street of renders it takes exactly one, two syncs in a tick take exactly one, a hand that ends inside the request leaves nothing held, and the browser taking it back leaves us able to take it again. Announced once with a Turn off button rather than a permanent header indicator; a setting after that. And localStorage is evictable, which the Save & reset panel now says out loud. persist() is asked for on Post blinds & deal, because every browser wants that request to come from something the dealer did. 885 tests pass, 18 of them new. Five fault injections into the wake lock are each caught. One of the new tests was green for the wrong reason first: globalThis.navigator = fake does nothing, because Node has defined navigator as a getter since v21. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb |
||
|
|
cd57c16050 |
Let the table tell the dealer who won
An option — off by default, and off means the tool is exactly what it was.
Turn it on and the community cards go on the felt. Each street asks for the cards as
it is dealt: the picker opens on the flop the moment the flop button is pressed, and
again on the turn and the river, because the moment a dealer is holding those cards is
the only moment they know what they are without turning the board round. Three taps,
then one, then one. The picker is the same sheet as the hole cards, which now knows
about the middle of the table too — the seven of clubs cannot be on the flop and in
somebody's hand, and until now it could.
Turning the players' cards over is unchanged. That already worked the way it should.
What is new is what happens next. Resolve Pot stops asking who won and says so:
Alexandra wins $50 with three of a kind, nines.
with every hand at the table read out under its player's name, the winner already
ticked, and the button reading "Pay Alexandra". A chop comes out as "Christopher and
Mike from work split it, $22.50 each, with a pair of aces", both ticked. The dealer
still confirms — the tool reads the cards, a person still says the pot is good, and a
hand entered wrong is corrected by unticking rather than by starting over.
It declines rather than guesses. A board that isn't five cards, a player who hasn't
turned over, the same card entered in two places: each says which of those it is and
hands the question back, and the wizard is the one it always was. Who is *ahead* and
who *won* are different claims and only the second one can pay somebody.
Side pots are read per round, not once. The best hand takes the main pot and is then
out of the question; the side pot goes to the best of who is left, which is a different
answer and the one the tool has to ask for again.
handRank.js is the only module here that can pay the wrong person without anybody
noticing, so it is built to be checkable rather than clever: rank five cards, then take
the best of the twenty-one five-card hands in seven. Beside the written cases — every
category against every other, the wheel and the steel wheel, three kickers deep, the
counterfeited two pair, the board that plays — it is checked against a second evaluator
written a different way, on twenty thousand random seven-card hands and four thousand
random pairs of them. An ace that keeps its value in the wheel, a dropped two-pair
kicker and a flush read only by its top card were each introduced on purpose and each
caught.
The board takes the middle of the felt, sized from the room the seat ring actually
leaves — measured against every seat rather than the ring's widest point, which is only
the answer for a seat sitting exactly on the horizontal axis and had two pucks on top
of the board while claiming 77px of clearance either side. Where there is no middle to
take, it moves to a strip above the table: a phone on its side has a felt 528x83 with
55px pucks, so the ring closes over the centre entirely and a board in it is a board
with players painted on top.
That strip found an older bug. It costs the felt height, and a felt that changes size
had only ever been re-measured, never laid out again: the seats follow it on their own,
being placed in percentages, but a bet chip anchored inside a seat is placed in pixels
off that seat's centre. Measured at 640x360, the chips were positioned for a felt 83px
tall and painted on one of 60 — seat 3's $5 came to rest on seat 4's puck, and two
pairs of seat pucks were overlapping. The bar growing mid-hand does the same thing: a
side pot appearing is 55px off the felt. Both fixed.
The sweep grew a third table — the same five players in the same nine chairs as the
second, with the board on, so the only thing that differs between those two rows is the
board itself. It earned its place on the first run.
728 unit tests. 216 audits: what is left is the seat ring hanging past the rail on a
landscape phone, the known residual, plus five or six pixels more of it on the layouts
where the strip takes the felt's height, and a blind badge over a stack on a packed
twelve-seat portrait table.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
|
||
|
|
e9bc9afcdb |
Seat a player when the dealer names one, and stop hiding the bar on a full table
The first thing anybody does with this tool is seat the table, and it didn't work. Measured on a fresh table: click a seat, type a name, repeat six times — six names on the felt, six seats still not in the hand, and no action bar at all. No "post blinds and deal", no explanation, nothing to press. Six people at the table and a blank screen with no way forward. Three things were wrong and each hid the next. Naming a chair and dealing that chair in were separate acts. Putting a name on an empty chair is somebody sitting down, so it now deals them in. Only a chair that was empty — no name and nobody in it: renaming someone who is sitting out is a correction, not an arrival, and dealing them back in over a typo would be the tool overruling the dealer. The action bar hid itself whenever no seat was live, which was written for an empty felt and caught a named one too. A table with names on it is not an empty table; it's a table the dealer needs to be told something about. And the seat editor never redrew the bar. It refreshes narrowly on purpose — a full render on every keystroke would take the name field out from under the dealer mid-word — but the bar answers "is there a table here at all", and seating somebody is exactly what changes that answer. Even ticking "In hand" by hand left the bar hidden until something else happened to redraw. Verified the fix keeps focus and the caret where they were, typing a character at a time. For the states that can still reach it — a table where everyone was sat out, a save from before naming seated people — the prompt names them and offers "Deal in all 6" rather than a hunt through six seat editors. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb |
||
|
|
a92a389245 |
Work out who pays whom
I went looking for what home-game dealers actually struggle with rather than reading
the code again, and the answer that kept coming up was the end of the night: hosts
reach for a separate app to finish a game "knowing exactly who owes whom in seconds
rather than minutes".
This tool already had every number that answer needs. The standings panel knows what
each player is up or down -- chips in front of them less the cash they handed over --
and then stops. Which leaves the dealer with six signed numbers at 2am and the actual
job still to do. That last step is the part people get wrong, not the poker.
Ann +$75 Dee +$110 Fay -> Dee $70
Ben -$10 Eve -$40 Cal -> Dee $40
Cal -$65 Fay -$70 Cal -> Ann $25
Eve -> Ann $40
Ben -> Ann $10
Largest debt against largest win, repeatedly. Greedy rather than optimal, which is
the right trade here: the true minimum number of transfers is NP-hard, while this
needs at most one payment fewer than there are people and each one it emits clears
somebody out of the list entirely. A dealer wants a short list to read down, not a
proof. Payers are the outer loop so everything one person owes prints together --
the list gets read out one player at a time.
All of it in integer cents. A settlement that has to come out exactly right is the
last place to let a float land on 41.99999999.
When the chips on the table don't match the cash taken
Then the payments can't all be made, and that is reported rather than quietly spread
across them. It is never rounding -- the arithmetic is integer cents -- so it is
always what the bank check sitting right above it is already warning about: a rebuy
nobody wrote down, or a miscount. Inventing a transfer to make the columns agree is
how a real person ends up short.
$100 of winnings has nothing to pay it -- there are more chips on the table than
cash was taken for. Fix that above before anyone cashes out.
The payment list goes into the CSV export too, under the standings it came from,
with the caveat if there is one. Whoever opens that file wants the answer, not to
redo the arithmetic in a spreadsheet.
13 tests (185 total): everyone ends up square, at most n-1 payments, a payer's lines
stay together, cents survive, nobody pays themselves, no payment is ever for zero,
both directions of an unbalanced bank, and one that settles a real table's standings
rather than hand-made numbers.
Driven through the UI at six viewports, balanced and not: clean layout audit and
contrast everywhere but the landscape phone already known from #63. Checked with
26-character names and seven-figure stacks on a 360px screen -- names wrap, amounts
stay whole and right-aligned, nothing overflows.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
|
||
|
|
a488bbf832 |
Stop rebuilding the hand log after every fold, call and raise
Profiled a long night at a full table — 24 seats, 200 logged hands — because
that's the state the tool is slowest in and the one a dealer reaches at 2am.
A full re-render cost 30ms, and it runs after every single action. Broken down,
25.7ms of that was renderHandHistory: tearing down and rebuilding 200 rows of a
panel that hadn't changed and that nobody is looking at mid-hand. Everything
else on the table put together came to about 4ms.
The log only changes when a hand is settled or the history is cleared — maybe
once every thirty taps. It now carries a cheap signature (length, hand counter,
and the newest row) and is skipped when that hasn't moved. Entries are immutable
once written and only ever added at the front, so the signature catches a
recorded hand, a cleared history, and an undo that puts a hand back.
renderAll, every action 30.07ms -> 4.18ms
renderHandHistory, skipped 25.74ms -> 0ms
renderHandHistory, rebuild 25.74ms -> 9.05ms
The rebuild got faster too, for the times it does run — once per hand, where
25ms of jank lands exactly on the "next hand" tap. toLocaleTimeString was the
single most expensive call in the loop and a logged hand's timestamp never
changes, so the formatted string is kept; and the rows are built into a fragment
so the panel lays out once instead of two hundred times.
A cache like this is only worth having if it can't go stale — a dealer reading
last hand's payouts while settling this one is worse than a slow panel. 10 tests
cover the invalidation directly: unchanged through a street of betting, changed
by settling, by clearing, by an undo back to a shorter log, by two hands that
differ only in the pot, and by the counter moving on.
Validated with Playwright against the real page: the rendered rows match the
stored history through settling a hand in the wizard, undoing it, clearing the
log and recording again. Touch and layout guarantees from the earlier rounds
still hold — 0 of 87 controls on touch-action auto, 0 tap-highlighted, every
stack still inside its own puck. 109 tests passing.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
|
||
|
|
db208a1a86 |
Make big numbers readable, and make the export add up
Seeded a table that looks like a real game — "Mike from work", "Jean-Philippe", stacks from $12 to $3,200,000 — and measured what the layout does with it. ".00" was costing a third of a seat puck Every amount rendered with two forced decimal places. Measured, the trailing ".00" is ~22px, against the 64px a seat puck has to show a stack in. So "$1,250,000.00" needed 102px of a 64px box — and because a number has no spaces to break on, it neither wrapped nor ellipsised. It drew straight out over the seat next to it. Money now shows cents only when there are cents. A chip game deals in whole numbers nearly all the time, and "$12" says exactly what "$12.00" said in half the width. A quarter-blind game is untouched: $0.25, $12.50 and $0.75 pots all still read as before, checked against a 25c/50c table. That alone took "$1,250,000.00" down to "$1,250,000" — 102px to 80px, still wider than the 64px puck. The remaining overflow is handled by stepping the font down on long amounts, which keeps every digit. Abbreviating to "$1.25M" would have fit more comfortably and was the obvious move, but a dealer reading a rounded stack off the felt is a worse failure than a small one, and the exact figure is the entire point of the tool. Measured after: every stack now paints inside its own puck, nothing spills onto a neighbour. Because Money.format is the single formatter all 47 call sites go through, this lands on the seat pucks, action bar, pot breakdown, seat menu, standings and history at once — the payoff from consolidating it earlier. The CSV export couldn't be added up The file exists so a house manager or a disputed chip count has something that outlives the tab, and every money column in it was written as text: "$1,250,000.00". The commas were quoted correctly so the file always parsed — it just imported as strings, so the one thing anyone opens it to do, total a column, needed every cell cleaned first. Money columns now go in as bare numbers (1250000.00) with no symbol and no separators. The columns meant for humans stay human: names are still names, payouts still read "Alexandra: $1,250,000", and a name with a comma in it is still quoted. 12 new tests (88 total, all passing), including the case that would be ruined by dropping cents unconditionally, and a proper quote-aware CSV parser to check every money cell parses as a number. Re-ran the occlusion sweep from the previous round at 9 seats, 10 seats and tournament stacks: still clean, no regression from the font changes. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb |
||
|
|
c1a484b97a |
Pay down the Hold'em dealer's tech debt and give it a test suite
Every module in the dealer ends with a `module.exports` guard, so they were all written to be testable, and none of them were ever tested. The correctness fixes that went in recently were checked by hand in a browser console that has since been closed. This puts a real safety net under them and clears out the duplication that made those bugs possible in the first place. Tests (58, ~300ms, no dependencies) Node's built-in runner, no package.json and no install step: the modules are plain script-tag files, so `load.js` just requires them and puts them on globalThis under the names they use to find each other in the page. The randomised suite is the part that matters. Every bug found in this tool so far has been shape-dependent — blinds posted to empty chairs, the button skipping players, heads-up acting out of order — and none of them show up on the full six-handed table anyone would write a fixed example for. It deals 1000 hands per property across random seat counts, empty chairs, stack depths and dealer input, checking that chips are never created or destroyed, that the pot breakdown plus uncalled returns equals the money in, that paying out settles to the cent, and that the button and blinds always land on real players. The generator is seeded so a failure reproduces instead of flaking. One implementation each - `Math.round(x * 100) / 100` was written out 16 times across 6 files. It's `Money.round` / `toCents` / `fromCents` now, in the module both pages already load first. - The chip epsilon was declared twice with the same value under two names (`PotCalculator.EPSILON`, `Betting.EPSILON_CHIPS`). Both now point at `Money.EPSILON`. - `Betting.seatAt` and `HoldemState.nextSeatIndex` were the same modular arithmetic written twice. Walking the seats is exactly where the button, the blinds and the action drifted apart from each other before, so there is one of it now. - The undo snapshot/restore dance appeared three times in main.js, each one re-deleting every key on `state` in place. It's `snapshotState` / `restoreState` once, with the reason it can't just reassign written down. A bug the tests found `layers()` and `uncalledReturns()` both decide what money was never contested, separately, and disagreed. `uncalledReturns` measured each player against the biggest live contribution *including their own*, which makes the biggest bet its own yardstick — so the one case it exists for came back empty, and `layers()` announced the overbet as a side pot only the bettor could win. With Ann shoving 200 into Ben's 60, the dealer was told "main pot 4, side pot 3, side pot 116, side pot 140" — four pots, one of them fictional. It now reads "main pot 123, uncalled 140 back to Ann", which is what a dealer says out loud. Layers are derived from contributions with the uncalled money already removed, so the two can't disagree again. A band every payer folded out of is now merged into the pot below rather than dropped, which was quietly shrinking the announced pot in another edge case. Validated with Playwright against a real hand on both surfaces — the table's live pot readout and Quick Split's all-in mode — plus undo, with no console errors. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb |