Tech-debt round for the Texas Hold'em dealer. Stacked on #42 so the diff shows only this work.
A test suite it was already written for
Every module ends with a module.exports guard — they were all written to be testable, and none of them ever were. The correctness fixes in #42 were checked by hand in a browser console that has since been closed.
58 tests, ~300ms, no dependencies: Node's built-in runner, no package.json, no install step. tests/holdem/load.js requires the plain script-tag modules and puts them on globalThis under the names they use to find each other in the page.
node --test tests/holdem/*.test.js
Wired to CI in a new holdem-tests.yml (left playwright-tests.yml alone).
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, an overbet announced as a side pot. None of them show up on the full six-handed equal-stack table anyone would write a fixed example for.
invariants.test.js deals 1000 hands per property across random seat counts, random empty chairs, random stack depths and random dealer input, checking:
chips are never created or destroyed during play
the pot breakdown plus uncalled returns always equals the money that went in
paying the pot out settles it to the cent
once uncalled chips are handed back, nobody is still betting above the whole table
the button and both blinds always land on players actually in the hand
The generator is seeded, so a failure reproduces from the seed in the message rather than being a flake.
One implementation each
Was
Now
Math.round(x * 100) / 100 written out 16 times across 6 files
Money.round / toCents / fromCents
Chip epsilon declared twice under two names (PotCalculator.EPSILON, Betting.EPSILON_CHIPS)
both point at Money.EPSILON
Betting.seatAt and HoldemState.nextSeatIndex — the same modular arithmetic, twice
one implementation
The undo snapshot/restore dance, three times in main.js
snapshotState / restoreState
Walking the seats is exactly where the button, the blinds and the action drifted apart from each other before, so that one is worth more than the line count suggests.
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 the function exists for (you shove, a short stack calls for less) always came back empty. layers() then announced that same money as a side pot only the bettor could win.
Ann shoves 200 into Ben's 60, blinds 1/2:
Announced
Before
main pot $4, side pot $3, side pot $116, side pot $140 — four pots, one fictional
After
main pot $123, uncalled $140 back to Ann
Layers are now derived from contributions with the uncalled money already removed, so the two can't disagree again. Separately, a band every payer folded out of is merged into the pot below instead of dropped — that was quietly shrinking the announced pot in another edge case.
Validation
node --test green at 58/58, plus Playwright against a real hand on both surfaces: the table's live pot readout, Quick Split's all-in mode, and undo. No console errors on either page.
Tech-debt round for the Texas Hold'em dealer. Stacked on #42 so the diff shows only this work.
## A test suite it was already written for
Every module ends with a `module.exports` guard — they were all written to be testable, and none of them ever were. The correctness fixes in #42 were checked by hand in a browser console that has since been closed.
58 tests, ~300ms, **no dependencies**: Node's built-in runner, no `package.json`, no install step. `tests/holdem/load.js` requires the plain script-tag modules and puts them on `globalThis` under the names they use to find each other in the page.
```bash
node --test tests/holdem/*.test.js
```
Wired to CI in a new `holdem-tests.yml` (left `playwright-tests.yml` alone).
### 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, an overbet announced as a side pot. None of them show up on the full six-handed equal-stack table anyone would write a fixed example for.
`invariants.test.js` deals **1000 hands per property** across random seat counts, random empty chairs, random stack depths and random dealer input, checking:
- chips are never created or destroyed during play
- the pot breakdown plus uncalled returns always equals the money that went in
- paying the pot out settles it to the cent
- once uncalled chips are handed back, nobody is still betting above the whole table
- the button and both blinds always land on players actually in the hand
The generator is seeded, so a failure reproduces from the seed in the message rather than being a flake.
## One implementation each
| Was | Now |
| --- | --- |
| `Math.round(x * 100) / 100` written out **16 times across 6 files** | `Money.round` / `toCents` / `fromCents` |
| Chip epsilon declared **twice** under two names (`PotCalculator.EPSILON`, `Betting.EPSILON_CHIPS`) | both point at `Money.EPSILON` |
| `Betting.seatAt` and `HoldemState.nextSeatIndex` — the same modular arithmetic, twice | one implementation |
| The undo snapshot/restore dance, **three times** in `main.js` | `snapshotState` / `restoreState` |
Walking the seats is exactly where the button, the blinds and the action drifted apart from each other before, so that one is worth more than the line count suggests.
## 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 the function exists for (you shove, a short stack calls for less) always came back empty. `layers()` then announced that same money as a side pot only the bettor could win.
Ann shoves 200 into Ben's 60, blinds 1/2:
| | Announced |
| --- | --- |
| Before | main pot $4, side pot $3, side pot $116, **side pot $140** — four pots, one fictional |
| After | **main pot $123, uncalled $140 back to Ann** |
Layers are now derived from contributions with the uncalled money already removed, so the two can't disagree again. Separately, a band every payer folded out of is merged into the pot below instead of dropped — that was quietly shrinking the announced pot in another edge case.
## Validation
`node --test` green at 58/58, plus Playwright against a real hand on both surfaces: the table's live pot readout, Quick Split's all-in mode, and undo. No console errors on either page.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Tech-debt round for the Texas Hold'em dealer. Stacked on #42 so the diff shows only this work.
A test suite it was already written for
Every module ends with a
module.exportsguard — they were all written to be testable, and none of them ever were. The correctness fixes in #42 were checked by hand in a browser console that has since been closed.58 tests, ~300ms, no dependencies: Node's built-in runner, no
package.json, no install step.tests/holdem/load.jsrequires the plain script-tag modules and puts them onglobalThisunder the names they use to find each other in the page.Wired to CI in a new
holdem-tests.yml(leftplaywright-tests.ymlalone).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, an overbet announced as a side pot. None of them show up on the full six-handed equal-stack table anyone would write a fixed example for.
invariants.test.jsdeals 1000 hands per property across random seat counts, random empty chairs, random stack depths and random dealer input, checking:The generator is seeded, so a failure reproduces from the seed in the message rather than being a flake.
One implementation each
Math.round(x * 100) / 100written out 16 times across 6 filesMoney.round/toCents/fromCentsPotCalculator.EPSILON,Betting.EPSILON_CHIPS)Money.EPSILONBetting.seatAtandHoldemState.nextSeatIndex— the same modular arithmetic, twicemain.jssnapshotState/restoreStateWalking the seats is exactly where the button, the blinds and the action drifted apart from each other before, so that one is worth more than the line count suggests.
A bug the tests found
layers()anduncalledReturns()both decide what money was never contested, separately, and disagreed.uncalledReturnsmeasured each player against the biggest live contribution including their own — which makes the biggest bet its own yardstick, so the one case the function exists for (you shove, a short stack calls for less) always came back empty.layers()then announced that same money as a side pot only the bettor could win.Ann shoves 200 into Ben's 60, blinds 1/2:
Layers are now derived from contributions with the uncalled money already removed, so the two can't disagree again. Separately, a band every payer folded out of is merged into the pot below instead of dropped — that was quietly shrinking the announced pot in another edge case.
Validation
node --testgreen at 58/58, plus Playwright against a real hand on both surfaces: the table's live pot readout, Quick Split's all-in mode, and undo. No console errors on either page.