8 Commits

Author SHA1 Message Date
prosolis
f8b07d8e6c games: the buy-in and the rake each player sees are their own, not the table's
The two-browser pass found it: at a table two humans share, the felt quoted
each of them the pair's total. "Bought in for 200" to a player who put in 100,
and a session-rake line that climbed on a pot the other one won.

Both were table totals the view read straight off the engine — correct while a
table had one human, wrong the moment it had two. Fixed along the border it
already draws: bought_in is border accounting, so it comes from the viewer's own
game_seats.staked; session rake is a within-table event, so it rides a new
per-seat Seat.Paid beside the audit's table-total s.Paid.

And top-up never grew game_seats.staked, so the storage invariant drifted by
every top-up and the felt under-reported the buy-in — it does now.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 17:17:25 -07:00
prosolis
5139385350 games: the poker table others can walk up to, and the one that empties when they leave
Phase C's handler cutover: hold'em now runs on the shared-table runtime instead
of the solo game_live_hands blob. Solo is just a table nobody else has joined.

- holdem implements tableGame (name/timeout/stacks). timeout auto-checks-or-folds
  a walked-away seat and marks it away; the audit is per-hand, with each pot's
  rake on the winner's row alone so HouseTake cannot 4x itself.
- New endpoints: sit opens a table (or joins an open bot seat), leave gets you up
  (LeaveTable + CloseTable behind the last human), plus tables (lobby), stream
  (SSE), chat and say. The move path loads the player's table, applies at their
  seat, commits under the version guard, and fans an SSE nudge.
- Engine grows Vacate/Occupy (a human leaving/joining between hands) and
  TableSeats (a named human + bots). The view carries your_seat, since a shared
  table has no seat-zero-is-you convention.
- Storage grows OpenSoloTable (stake+claim+create+seat in one tx), PlayerSeat,
  and the abandoned-table reaper (AbandonedTables/ReapTable) — the seated-player
  counterpart to the session reaper, since a walked-away stack is inside a blob
  the session reaper cannot see. upsertSeat preserves last_seen so an auto-fold
  never refreshes an away player's clock.

Not deployed, and the felt is not rewired yet: the frontend still assumes
seat zero is you, so this is browser-unverified. Solo sit/deal/play/leave and
two-human join/leave/reaper are covered by tests; the whole suite is green.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 16:37:49 -07:00
prosolis
5b381b03ff games: the poker engine learns there can be more than one of you
Phase C, the engine half: hold'em becomes multiway, and the redaction that was a
bug-in-one-handler becomes the security boundary the plan warned it would.

- const You is gone. A table is a list of seats and which are human is a per-seat
  property, not the fixed index zero. New(tier, []SeatConfig, ...) seats the ring;
  SoloSeats builds the old one-human-plus-bots shape the solo handler still opens.
- ApplyMove(state, seat, move) — seat identity enters the engine in exactly one
  place; every helper below already worked on indices. The advance loop stops at
  any human (not just seat 0), so one request plays the bots and hands control
  back at whichever person is next to act.
- deal() now emits every seat's hole cards. The engine cannot redact a stream it
  doesn't know the audience of, so it stops trying: the view layer builds each
  viewer's redacted copy. viewHoldem/viewHoldemEvents take a viewerSeat.
- Rake attributed to Paid whenever a *human* wins, not just seat 0 — real house
  income is rake off any player's pot, and bot pots are house-vs-house.
- Bust is per-seat: at a solo table it still ends the session (PhaseDone), at a
  shared one a busted human just goes Out and the table plays on.

Tests, three ways, all green:
- the solo suite unchanged as a regression guard (a test-local You=0 alias);
- TestMultiwayChipsAreConserved — 100 games, two humans at seats 0 and 2, chips
  counted after every move, proving the reshape actually plays;
- TestHoldemViewNeverLeaksAnotherSeatsCards — renders every seat's view and event
  stream at every street and greps for anyone else's cards. Mutation-tested: undo
  the redaction and it fails on the preflop deal.

No handlers rewired yet — the solo path still calls New(SoloSeats(...)) and renders
for seat 0, so nothing a player sees has changed. The table cutover is next.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 16:05:19 -07:00
prosolis
03524aefbc games: the seat you sit in, and the seat you are left in
Six-handed, the felt printed CO on three seats at once. Position walked the
table with nextIn, which steps over folded seats, while the seat count it walked
against still included them — so every muck slid the anchors round and the
labels landed somewhere new. Folding the small blind relabelled it the cutoff.

The two walks are a pair and they are easy to confuse. nextIn asks who is still
in the betting; a fold takes you out of it. Position needs the other question —
who was dealt in — because where you sit is decided when the button moves and
does not change because somebody threw their hand away. So nextDealt, which
skips only the seats that are not in the hand at all, and a note at both of them
saying which is which.

The bots never read this. They use InPosition, which really does want the last
seat still live, and which is deliberately not this function. So the policy is
untouched and the money never moved — the only thing this ever broke was the
badge on the plate, which is precisely why nothing caught it.

TestPositionsDoNotMoveWhenSeatsFold deals six-handed, asserts the table prints
each of BTN/SB/BB/UTG/MP/CO exactly once, then folds the seats out from under it
one at a time and asserts nobody's label moves.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 11:42:18 -07:00
prosolis
4bc38859d4 games: the bots come back from school
The 20M-hand policy the trainer was running on millenia, collected. 4,159 nodes,
which is barely more than the 300k-hand placeholder had — the info-set
abstraction is coarse, so what twenty million hands bought is better-converged
strategies at the same decision points, not a bigger tree. The heads-up hit rate
is 94% and chips still conserve across a hundred sessions of real hands.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 10:07:55 -07:00
prosolis
b96879d25c games: the short stack that could win money it never matched
A review pass, and it found the one that would have cost somebody real chips.

Side pots were only ever cut in runout() — the path taken when the betting
stops because nobody is left able to bet. But a hand reaches a showdown with an
all-in player in it and the betting having finished perfectly normally: a short
stack shoves, two players who still have chips behind call, and then keep
betting past them street after street to the river. Nothing was cut. One pot,
everybody eligible, and the short stack takes the lot — every chip the deep
players put in after they were already all-in, money that could never have been
lost to them. All-in for 100 against two players who each put in 500, and the
best hand collects 1,100 instead of the 300 it was playing for.

Chip conservation never saw it. The chips balance perfectly; they just land in
the wrong seat. And every browser session went through runout(), because a
player shoving is what ends the betting. It took reading the code.

Also from the review: play() dereferenced a table it had just been handed as
null, the top-up button offered chips the wallet could not cover, and the
trainer's ETA was sixty thousand hands optimistic on the first line it printed.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:16:52 -07:00
prosolis
903c5accdb games: the rake you pay, and the rake the table lifts
They are different numbers and the felt was quoting the wrong one. Every raked
pot has chips lifted off it, whoever wins — that has to stay true or the table
stops balancing. But the bots' chips are not real, so a pot a bot wins costs
you nothing, and the counter under your stack was climbing anyway while you sat
there folding.

Rake is now every chip off the table (so the chips conserve) and Paid is the
part that came out of a pot you won, which is the only part that is money and
the only part worth telling you about. A chop costs you half of it. The audit
log takes Paid too: the house's income is what it made off the player, not what
it lifted off a bot.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:11:14 -07:00
prosolis
e6c1bd3b54 games: the poker table opens, and the bots go back to school
Phase 4. Hold'em, and it's the only table in the casino that is a session
rather than a game: you buy in, play as many hands as you like, and leave with
what's in front of you. So the live row spans hands and chips cross the border
exactly twice. Everything in between is inside the engine.

The bots move inside ApplyMove, as UNO's do, which is what keeps poker off a
socket: shove all-in and the flop, turn, river, showdown and payout all come
back in one response, as a script the felt plays back.

The CFR policy the plan called "the single highest-value asset in either repo"
was never read. Not once, in the whole life of the game: the trainer wrote its
info-set keys under IP/OOP and the runtime looked them up under BTN/SB/BB, so
every lookup missed and fell silently through to a pot-odds heuristic. Nothing
looked broken, because a policy miss is not an error. And it was the wrong
policy anyway — ten big blinds deep, trained on a tree where a call always ends
the street, which is not poker. So the trainer is rewritten to play the real
engine through the real reducer, at every stack depth the table deals, and the
trainer and the table now build the key with the same function so they cannot
drift apart again. A test fails if the bots stop finding themselves in it.

Three money bugs, and the tests earned their keep. Chip conservation across a
hundred sessions caught an uncalled bet that minted chips. A var-init ordering
trap meant every card was identical, every showdown tied and every bot believed
it held exactly 50% equity. And the browser caught the rake being silently
zero — the tier said 5 meaning percent, the casino handed it 0.05 meaning a
fraction, and integer division took the house's cut down to nothing.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:08:59 -07:00