36 Commits

Author SHA1 Message Date
thejayman77 e46712c4c7 Add a switchable table shape alongside the seat style
A second header chip cycles the felt shape, so table and seat treatments can be
compared together on a device rather than argued about from screenshots.

- Oval: ellipse fitted to the container. Seats step by equal angle, because even
  horizontal spacing on a deep ellipse always bunches the middle three.
- Arena: a circle wider than the screen. The left and right lobes fall outside
  and are clipped, trading side space nobody uses for a broad, shallow top arc.
  The arc drop from crown to outermost seat falls from roughly 143dp to 70dp at
  384dp wide, so seats spread across the full width instead of stacking.
- Stadium: racetrack. Flat across the middle, curving only at the ends.

Shape and seat arc are one decision, not two: seats are placed on whichever
curve the felt is drawn from, so they cannot drift off it the way hand-picked
lanes did. On the shallow shapes seats space evenly by x, which is exactly where
that works — the vertical differences are small enough to read as a gentle curve.

Tests cover all three shapes at every supported width for overflow and
collision, and assert arena flattens the arc relative to oval.

261 tests, 0 failures. Lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 07:34:27 -04:00
thejayman77 ba2675d6ff Fix card clipping and table reshape; add a live seat-layout switcher
Three things, two of them defects rather than taste.

Cards were double-clipped. CardImage applied a 6dp RoundedCornerShape clip over
artwork that already carries its own 4.8dp radius and a black border, so the
clip cut inside the card's own corner and shaved the border off. The clip and
its backing plate are gone; the art is drawn at the same 2:3 ratio it is laid
out at, so nothing is masked.

The felt reshaped between circular and oval on every raise. PokerTable is
weighted against the control column, so growing that column to hold the sizing
controls shrank the table and rounded the oval. Raise sizing is now a sheet
overlaid on the felt, which is what it was meant to be. Measured before and
after opening it: felt top 451 both, identical width, height within 7dp — the
remainder is the sheet's own gradient, not a reshape.

Seat layout is now switchable at runtime via a SEATS chip in the header, so
treatments can be compared on a real device instead of argued about from
screenshots:
- Plate: avatar and fan over a full-width name plate. Most information, widest.
- Badge: one horizontal pill, avatar beside name and stack. Half the height.
- Chip: avatar-led with name and stack beneath. Narrowest, most open felt.

Seat width is derived per style, so each variant keeps the arc geometry and
overflow guarantees the tests already enforce.

259 tests, 0 failures. Lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 07:23:40 -04:00
thejayman77 de0b96279d Step seats evenly around the arc and centre the pot among them
Previous attempts kept trading one crowding complaint for the other because
they all assumed evenly spaced x. That cannot also be evenly spaced around an
oval: solving an ellipse for evenly spaced x values always bunches the middle
three near the crown and strands the side seats, so every vertical lane value
fixed one end and broke the other.

Seats now step by equal ANGLE around the arc. The side seats fall to 0.295 of
table height — level with the board rather than pinned to the top — and the rest
space evenly from them.

Neighbouring seats may now overlap horizontally by a few dp at the shallow ends
of the arc. That is correct for an arc and harmless, because those pairs are
separated vertically by more than a seat's height. The test asserts separation
on at least one axis rather than demanding a horizontal gap, which is what
forced the edge-to-edge row in the first place.

The pot moves into the ring the seats enclose (0.30 of height) instead of
sitting stranded below it. The board stays where it was, clear of the lowest
seat plates.

Verified on Galaxy S24+ geometry: nothing clipped at either edge.

259 tests, 0 failures. Lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:34:59 -04:00
thejayman77 835d26cc7e Seat every player on the felt rail
Bruno and Dex floated on the felt interior while Ada, Cleo and Enzo straddled
the rail, so the row looked inconsistent. The cause was that vertical lanes were
hand-picked constants (0.30 / 0.15 / 0 / 0.15 / 0.30) with no relationship to
the oval they were meant to sit on — any value would put some seats on the edge
and others adrift, which is why tuning them kept trading one complaint for
another.

Seat placement now solves the felt ellipse for each seat's own horizontal
position, and reads the same FELT_LEFT/RIGHT/TOP/BOTTOM constants the felt is
drawn from. The rail and the seats can no longer disagree, and a test asserts
every seat lands on the rail at every supported width rather than merely
checking that lanes are ordered.

At 384dp the solved fractions are 0.247 / 0.095 / 0.055 / 0.095 / 0.247 —
a real curve rather than three seats abreast.

Seat gap widened from 6dp to 10dp so the name plates have breathing room now
that the shoulder seats sit nearer the crown.

Verified on Galaxy S24+ geometry: nothing clipped at either edge.

261 tests, 0 failures. Lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:26:17 -04:00
thejayman77 a11daddf6c Spread the seat arc and fan the hole cards
Two issues visible on the real Galaxy S24+ screenshot.

Shoulder seats crowded the crown. Lanes were 0.30 / 0.06 / 0 / 0.06 / 0.30, so
Bruno and Dex sat within 0.06 of Cleo and the three read as one packed row while
Ada and Enzo were stranded far below. Shoulders now sit at 0.15, the midpoint
between the crown and the side seats, so the row reads as a curve. A test pins
the shoulder near that midpoint rather than merely below the crown, since
"below" was already true at 0.06.

Hole cards looked like one red slab. Two 18x27dp cards overlapped by 5dp with no
rotation merged into a single block. They are now a proper fan: opposing
rotation, real offset, and tucked behind the avatar as in the reference design.

Fanning first pushed the rightmost seats off-screen, because a fixed 48dp avatar
plus the fan exceeded the 69.6dp slot a 384dp screen allows. Avatar and fan now
scale with the slot and share one slot-width box, so the pair can never overflow
the seat it was allotted. Verified: zero bright pixels in the right margin.

260 tests, 0 failures. Lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:19:44 -04:00
thejayman77 10a2056f7f Derive table layout from measured bounds instead of tuned constants
The recurring spacing and crowding problems shared one cause: geometry was
tuned against the Pixel emulator (427 x 952dp) while the target device is a
Galaxy S24+ (SM-S926U, 1080x2340 @ 450dpi = 384 x 832dp). That is 43dp
narrower and 120dp shorter — roughly two button rows — so the emulator
consistently hid the failures instead of showing them.

Seat crowding. Seat width was fixed at 88dp while positions were fractions of
width, so seats collided as the screen narrowed. Measured at 384dp, ALL FOUR
adjacent pairs overlapped: -30.4, -3.5, -3.5, -30.4 dp. Seat width now derives
from the measured container so five seats always fit with equal real gaps, and
revealed cards scale within the slot rather than widening it. TableOrbitTest
asserts no overlap and even spacing at 320/360/384/411/427/480dp, so the narrow
case cannot regress unnoticed again.

Raise panel rhythm. A Material Slider paints a ~16dp track inside a 48dp
accessibility touch target. Laid out at 48dp it injected 16dp of invisible
padding above and below, so a uniform declared gap rendered as ~20dp around the
slider and ~4dp between the filled buttons. That is why tuning the uniform
number never worked: the error is a constant offset, not a proportional one, and
tightening 12dp to 4dp made the ratio worse (2.3x to 5x). The slider now
reserves only its painted height via requiredHeight while keeping the 48dp touch
target.

Control contrast. Back and +/- buttons were 9% white on near-black, too faint to
read as blocks, so the eye measured gaps between ink rather than layout bounds.
Raised to 16%, presets to 12%.

Grouping. The confirm action is separated from the three sizing rows rather than
evenly spaced among them; they set a value, it commits one.

Measured on S24+ geometry, gaps between the sizing rows went from
[23.1, 20.3, 3.9] (spread 19.2dp, visibly shrinking) to [14.6, 14.6] with a
deliberate 26.3dp break before Confirm — spread 0.0dp within the group.

Reclaimed space. The table was aspect-ratio locked, leaving 178dp of dead felt
(21% of the display) between the hero cards and the controls once the two-stage
raise freed it. The table now fills the available height and the felt is an oval
sized to its container rather than a width-derived circle.

259 tests, 0 failures. Lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:01:23 -04:00
thejayman77 e6a387478f Tighten raise panel spacing 2026-07-27 11:05:03 -04:00
thejayman77 5f4b410f70 Normalize raise panel spacing 2026-07-27 11:01:25 -04:00
thejayman77 b6ea330e07 Add two-stage raise confirmation 2026-07-27 10:50:41 -04:00
thejayman77 33be35f970 Rework table spacing and betting controls 2026-07-27 10:24:50 -04:00
thejayman77 7268d62790 Improve table legibility and showdown focus 2026-07-26 22:18:39 -04:00
thejayman77 df4d90f40b Polish table and clarify showdown results 2026-07-26 21:46:28 -04:00
thejayman77 b8087a9872 Add language-only opponent personas 2026-07-26 20:42:36 -04:00
thejayman77 843957d154 Make player history failures non-fatal 2026-07-26 19:40:58 -04:00
thejayman77 1ec3dd1276 Persist cross-session coach history 2026-07-26 19:04:29 -04:00
thejayman77 369b1f59d4 Exercise short-stack calibration paths 2026-07-26 16:40:06 -04:00
thejayman77 4d8a62afb6 Add deterministic post-action coach 2026-07-26 15:27:25 -04:00
thejayman77 3aa55dd2ce Add cash game session setup and reload 2026-07-26 14:55:15 -04:00
thejayman77 14c35f18a3 Show live table action status 2026-07-26 14:39:51 -04:00
thejayman77 6e0766534a Identify hero results by seat 2026-07-26 14:33:03 -04:00
thejayman77 9eb77a27a2 Pause between hands and show results 2026-07-26 11:11:48 -04:00
thejayman77 db1f96b421 Make bot traces and position adjustments honest 2026-07-26 10:42:11 -04:00
thejayman77 0ac69b101a Replace bot tuning guesses with enforced calibration 2026-07-26 05:13:54 -04:00
thejayman77 fffd60ad9d Tune the skill axis: profiles now play like their labels
The reported symptom was a "Rock" at 41% VPIP against a 12% setting. Chasing it
uncovered four separate places where a *skill* parameter was smuggling in a
*style* change — the two axes were supposed to be independent.

1. Error direction. blunder() pushed every mistake the same way, so a 30% error
   rate put any beginner near a 30% VPIP floor regardless of style. Making it
   style-directed fixed the Rock but collapsed the gradient, because a tight
   player's errors then became folds, which cost almost nothing. Errors are now
   split: pre-flop follows the player's character (a nit's mistake is folding a
   hand they should have played), while post-flop stays costly for everyone —
   paying off when beaten and checking back hands worth betting. That is also
   where weak players genuinely lose money.

2. potOddsRespect shifted weak players systematically toward calling. That is
   not a weakness — calling wider than break-even against bad opponents is a
   winning adjustment, so it handed low-skill bots a real edge. Discipline now
   means ACCURACY: a weak player misjudges the threshold in either direction.

3. OpponentModel treated 0.5 as a neutral bet/raise share. Folds, checks and
   calls are counted too, so a normal player sits near 0.32 — every opponent
   read as passive, and the only two levels that consult the model tightened
   against the whole table and lost money for it. The exploitation feature was
   a handicap. Baseline calibrated and named.

4. positionAwareness widened 45% in position but narrowed 25% out of it. A seat
   is last to act about a quarter of the time, so the tighter branch dominated
   and higher awareness silently meant fewer hands. Position now shifts WHERE
   hands are played, not how many.

Also: the pre-flop slop multiplier now saturates (multiplying pushed a 0.75
maniac to 0.93 while still drowning out the tight end), raw pot odds carry an
implied-odds discount, and skill levels are re-spaced.

Results: Rock 41.3% -> 14.5% VPIP, every style ordered correctly by looseness,
and win rates down from ~113 to ~20 bb/100 for a strong seat.

HONEST LIMITATION: Advanced and Expert are not separable. Over 100k hands their
order flips with the seed. The simulator now asserts each level beats the one
two tiers below it — true on every seed tried — rather than strict adjacent
ordering, which would be reading noise as signal. Separating the top two needs
either a wider parameter gap or a different distinguishing mechanism.

New ProfileBehaviourTest is the regression that was missing: it asserts styles
actually produce their own behaviour. A gradient can look healthy while every
profile is misnamed, which is exactly what happened.

Tests: 154 -> 166 (75 engine JVM, 75 Android host, 16 app).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:52:13 -04:00
thejayman77 dc136b7ecc Route every call-resolving path through canonicalCall
Direct calls were canonicalised last commit, but the two downgrade paths inside
the BET/RAISE branch still built Action(CALL, toCall) with the unclamped amount:
an illegal raise falling back to a call, and a player barred from re-raising
after an incomplete all-in. A short stack there recorded chips that never moved.

That path matters more than the direct one — it is where malformed agent output
lands, which is precisely what an LLM will produce once the persona layer exists.

All three now go through one canonicalCall(seat, toCall). There is exactly one
construction of a CALL action left in the file, which is the point: three copies
of this rule is how it went wrong twice.

Regressions for both paths, each verified to fail with only its own path
reverted — recording 90 instead of 30, and 30 instead of 20.

Tests: 150 -> 154 (69 engine JVM, 69 Android host, 16 app). Simulation figures
unchanged (289.12 / 113.19 / -195.64), chips conserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:08:01 -04:00
thejayman77 c0bf57bf8d One clamp, one contract: callAmount owns what a call costs
The visible label was right but the contract behind it was not. The clamp lived
in three places — DecisionOffer.callAmount (unclamped, so it still reported the
full amount owed), buttonsFor(), and PokerViewModel — and only the presentation
copy was tested. Three copies of a rule is how the original mismatch happened.

DecisionOffer.callAmount is now minOf(toCall, stack), with callIsAllIn beside
it, and both the label and the submitted action read from it. Nothing recomputes
the clamp.

Engine sanitisation also left an oversized CALL amount unchanged in HandEvent
even though commit() caps the commitment, so history described chips that never
moved. Since replay and the coach both read that history, calls are now recorded
at what was actually committed.

Note the arithmetic: heads-up, seat 1 is the big blind, so with a 40 stack
facing a raise to 100 the call commits the remaining 30, not 40. My first test
asserted 40 and the engine was right.

Verified by reverting: both contract fixes fail their tests.

Tests: 144 -> 150 (67 engine JVM, 67 Android host, 16 app). Simulation figures
unchanged (289.12 / 113.19 / -195.64), chips conserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:59:26 -04:00
thejayman77 4f1ef45dd7 Quote the real price when a call exceeds the stack
Facing more than you have left is a call for your whole stack — commit() clamps
the commitment — so labelling it "Call 35" when only 20 can be paid promised a
price the player could neither make nor owed. It now reads "All in 20".

The ViewModel also submits the clamped amount rather than relying on the engine
to fix it up, so the action sent never disagrees with the label shown.

Verified by reverting: both new label tests fail.

Tests: 141 -> 144 (64 engine JVM, 64 Android host, 16 app).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:53:25 -04:00
thejayman77 f0c8a7c430 Fix legal-raise omission, hand header, and decision identity
1. Raise could vanish when it was legal. The action bar required
   maxRaiseTo > minRaiseTo, which hid an exact-minimum raise and any legal short
   all-in — both of which the engine accepts (sizeBet clamps a stack too short
   for a full min-raise to maxRaiseTo). The button now shows whenever the engine
   would accept a raise; the slider only appears when there is a genuine range,
   and a single-size raise submits a fixed amount.

2. The header used handsPlayed + 1, which increments when a hand *finishes*, so
   during the showdown hold it labelled hand 1's result as "Hand 2". It now uses
   snapshot.handNumber, which is by construction the hand being displayed.

3. Decision identity is now owned by the engine. HumanAgent minted its own
   tokens, so liveOffer() had to approximate matching with hand + street + seat
   — ambiguous, because a player can face two decisions on one street (bet, get
   raised, act again). Table stamps one monotonic token per decision, carried on
   both DecisionContext and TableSnapshot.toActToken, so liveOffer() matches
   exactly and the residual race is gone rather than narrowed.

4. Presentation logic moved out of the composable into buttonsFor(), a pure
   function of the offer, so it is testable without a Compose runtime. The app
   module had no tests at all; it now has 13 covering raise visibility, fold
   suppression, labels, and every liveOffer() matching case.

Each new test was verified to fail with its fix reverted: reverting raise
visibility and token matching failed exactly four, and no others.

Tests: 62 -> 141 total (64 engine JVM, 64 Android host, 13 app).
Simulation unchanged, chips conserved. Verified on the emulator; physical device
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:48:07 -04:00
thejayman77 7382bc8638 Fix the fold experience: honest actions, matched state, action identity
Reported as "folding looks broken". It was five separate defects.

1. Fold silently became Check. sanitise() rewrote FOLD to CHECK whenever
   checking was free, so a UI showing a Fold button folded nothing and the
   player kept being asked to act. Folding is legal at any turn — it simply
   mucks — so FOLD is now honoured literally. The engine must never substitute a
   different action than the caller asked for. The reverse rewrite (an illegal
   CHECK facing a bet becoming FOLD) is legitimate and stays.

   Safe by construction: no bot emits FOLD when it can check, and the 20k-hand
   simulation reproduces byte-identical numbers (289.12 / 113.19 / -195.64).

2. Snapshot and offer could describe different moments. The 32-deep frame
   channel let the engine race far ahead of the animation, so the action on
   offer could belong to a later street, or another hand. The channel is now
   RENDEZVOUS, capping the engine at one frame ahead, and UiState.liveOffer()
   only surfaces an offer whose hand and street match the table on screen.

3. Stale and double taps could act on a later decision. DecisionOffer now
   carries a token; submit() requires it and rejects anything stale, so a second
   tap is dropped rather than applied to whatever comes next.

4. A real fold was invisible. The hero kept normal cards and no folded state, so
   a correctly processed fold looked like a bug. Cards now dim, FOLDED shows in
   red, and the action bar explains the player is sitting out.

5. Non-atomic UiState updates from two coroutines now use update {}.

Also: Fold is hidden when checking is free (folding a free hand is never
correct, and offering it invites an accidental muck), and onCleared no longer
calls human.cancel() — viewModelScope is already cancelled by then so the launch
never ran; scope cancellation already propagates into act()'s finally.

The delivery tests were weak as charged: no slow consumer, and not the app's
capacity. Replaced with a genuinely slow consumer measuring how far the engine
runs ahead — asserting <= 1 on RENDEZVOUS, and > 1 on a 32-deep buffer to
document why the buffer was removed.

Verified on the emulator (physical device untouched): folded facing a bet, hero
showed FOLDED, was never asked again that hand, and play advanced to hand 2.

Tests: 55 -> 62, green on jvmTest and testAndroidHostTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:04:44 -04:00
thejayman77 950c7ceb57 Playable Android table
The game runs on device: verified on a Pixel 10 Pro emulator (Android 17) by
installing, tapping through a hand, and confirming it advanced pre-flop to flop
with correct pot, folds, and re-offered action.

App:
- :app module on AGP 9.2.1. Note AGP 9 has built-in Kotlin support, so applying
  org.jetbrains.kotlin.android conflicts with it ("extension with name 'kotlin'
  already registered"); only android.application + kotlin.compose are applied,
  matching recipeze.
- PokerViewModel runs a continuous cash game and publishes to Compose.
- Compose table: opponents, board, pot, hero, action bar with a raise slider.

Frames are queued, not conflated. An all-in runout emits flop, turn and river
microseconds apart; pushing those into a StateFlow would collapse them and the
board would jump from empty to complete. The engine's suspending observer sends
into a Channel, a consumer paces each frame, and only then is StateFlow updated
— so backpressure paces the engine rather than the UI dropping frames. Three
tests cover this, including a characterisation test showing a conflating
StateFlow does lose the intermediate frames.

Assets:
- tools/generate_card_assets.sh rasterises the SVGs into four density buckets
  using sips, which renders SVG directly — no librsvg or ImageMagick.
- Resource names are prefixed card_ because Android resource names may not start
  with a digit (10_of_clubs would be rejected).
- CardArt.kt maps deck index to drawable via static R references, so R8 resource
  shrinking cannot strip the artwork the way getIdentifier lookups would risk.

Layout fixes found by actually looking at the running app: five opponents did
not fit a fixed-width scrolling row (Enzo was off-screen), the header collided
with the status bar clock, and the board floated against a large dead space.

Tests: 52 -> 55, green on jvmTest and testAndroidHostTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:23:34 -04:00
thejayman77 679b25e2c7 Publish the all-in runout street by street
During an all-in runout, dealRemainingBoard() dealt every remaining card
without touching currentStreet, so a pre-flop all-in produced a terminal
snapshot labelled PREFLOP carrying a five-card board.

Setting currentStreet = RIVER would fix the label but leave a second problem:
the board jumped from empty to complete in a single snapshot, so the UI could
not animate the runout — the moment a poker table most needs to. Instead
dealRemainingBoard() is now suspend and publishes each street as it lands,
which keeps currentStreet honest as a consequence rather than as a special
case. Each runout street also sweeps its betting into the pot via
prepareRound(), matching the normal street transition.

Verified by reverting: the board went 0 -> 5 with the terminal snapshot
labelled PREFLOP, and three of the four new tests failed.

Tests: 48 -> 52, green on jvmTest and testAndroidHostTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:04:30 -04:00
thejayman77 eefcd5966c Fix UI-boundary defects; run tests on the Android variant too
Two defects found reviewing the UI boundary before Compose work:

1. HumanAgent.awaiting was a plain mutable property written by the game
   coroutine and read by the UI — a data race, and invisible to Compose. It also
   exposed DecisionContext, which holds a live Seat whose fields mutate as the
   hand proceeds, so even a safe read could observe torn state. Replaced with an
   immutable DecisionOffer published through a StateFlow. A test mutates the live
   seat after publication and asserts the offer does not change.

2. STREET_COMPLETE was emitted after dealing the new street but before the round
   state was reset, so a flop snapshot carried pre-flop currentBet and
   committedThisRound — the UI would have painted last street's chips in front of
   every player alongside the new board. The reset is now prepareRound(), called
   before publishing. Verified: with the ordering reverted the new test fails
   with currentBet 10 on the flop.

Also:
- HumanAgent.cancel() is now covered directly; the previous test only cancelled
  the coroutine running act(). cancel() and submit() both report whether anything
  was actually pending.
- Android host tests enabled via withHostTestBuilder, so the shared suite runs
  against the Android variant instead of the AAR merely compiling.

No librsvg needed for card assets: sips rasterizes the SVGs directly at exact
2:3 dimensions, court cards and patterned backs included.

Tests: 45 -> 48, now green on both jvmTest and testAndroidHostTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:33:17 -04:00
thejayman77 7f82251d86 Android target, suspending agents, and observable table snapshots
Build:
- :engine now uses com.android.kotlin.multiplatform.library (AGP 9.2.1), the
  modern KMP Android integration, rather than plain androidTarget(). Produces
  engine.aar alongside the JVM target; compileAndroidMain verified.
- Version catalog added; SDK levels match the other JSJ apps (compileSdk 37,
  minSdk 26).

Engine:
- PlayerAgent.act() and Table.playHand() are now suspend, so a human player can
  wait for input without blocking a thread. Bots are unaffected; the simulator
  wraps in runBlocking.
- TableSnapshot/SeatSnapshot published after the deal, before and after every
  action, and at the finish. Immutable, aliasing no live Seat state, giving
  animation, hand history, saving, and replay one boundary to work against.
- Snapshots carry the whole truth; maskedFor(viewer) is an explicit step that
  hides hole cards the viewer is not entitled to. Showdown reveals contenders;
  folded hands never are.
- Terminal snapshots report the contested pot rather than 0. settle() zeroes
  contributions when awarding, so the naive value was empty at exactly the
  moment the UI needs to show what was won. Caught by a new test.
- HumanAgent suspends on a CompletableDeferred and clears its pending state in a
  finally block, so cancelling an abandoned hand releases the wait instead of
  stranding it. Re-usable afterwards; a stale submit returns false.

Tests: 37 -> 45. Chips still conserved, skill gradient still monotonic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 10:55:28 -04:00
thejayman77 96af198097 Correct PreflopChart percentile docs
The KDoc claimed the worst hand is percentile 1.0, but percentiles mark the
START of each class's band, so the last class begins at (1326-12)/1326 ~ 0.991
and no real hand reaches 1.0. Documents that this is correct for gating, since a
class is admitted when its band opens inside the range. Also notes that the
unused key slots keep 1.0 as an unreachable sentinel.

Docs only; no behaviour change. 37 tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:34:43 -04:00
thejayman77 f1222401fb Fix four rules and modelling defects found in review
TDA Rule 47 — cumulative incomplete raises:
A boolean could not express "facing at least a full raise since acting", so
several short all-ins that together reached a full raise failed to reopen
betting. Seat now records lastActedAtBet (the currentBet when it last acted);
mayRaise() reopens when currentBet - lastActedAtBet >= minRaiseSize. This
subsumes the single-incomplete-raise case, so the hasActed reset in apply() is
gone.

TDA Rule 20 — odd chips:
Split-pot remainders were awarded in seat-list order. They now go to the first
winning seat clockwise from the button. The old test also never produced an odd
pot (20 chips heads-up), so it only ever proved an even split; it now builds a
genuinely odd 25-chip pot via a folded small blind and asserts which seat takes
the extra chip.

OpponentModel skipped events across hands:
It inferred a new hand from a shrinking history, but history is cleared each
hand: having consumed 3 events, first observing the next hand at 4 events left
4 < 3 false and silently dropped the first three. observe() now takes an
explicit handNumber, exposed via DecisionContext and Table.handNumber.

PreflopChart percentile semantics:
The 169 classes were ranked equally, but they are not equally likely — a pair is
6 of 1326 combinations, suited 4, offsuit 12. "Top 12%" therefore meant 12% of
classes, not of dealt hands, so looseness did not mean what it claimed.
Percentiles are now weighted by combination count.

Tests: 30 -> 37. Each new test was verified to fail with its fix reverted.
Skill gradient still monotonic: 85.9 / 79.8 / 17.4 / -183.1 bb/100 over 50k
hands, chips conserved on both tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 05:03:11 -04:00
thejayman77 479be1f6b9 Initial commit: Hold'em engine, bots, and simulation harness
Kotlin Multiplatform engine (JVM target only for now; androidTarget and
iosArm64 slot in without touching commonMain).

Core:
- HandEvaluator: single-pass 5-7 card evaluation, ~24M evals/sec. Verified
  exhaustively against published frequencies for all 2,598,960 five-card hands.
- Equity: Monte Carlo with ties split. PreflopChart ranks the 169 starting
  hands using all-in equity plus an explicit playability adjustment, so
  looseness means "plays the top N%".
- Table: no-limit betting rounds, side pots, odd-chip splits, uncalled-bet
  refunds, and incomplete (short all-in) raises that correctly do not reopen
  betting.

Bots:
- SkillLevel and PlayStyle are orthogonal axes. Skill drives decision quality
  (rollout accuracy, pot-odds discipline, position awareness, error rate);
  style drives bluffing, sandbagging, aggression, tightness.
- BotMood gives tilt that persists between hands and decays.
- OpponentModel lets Advanced/Expert exploit habitual bettors.
- MathBot emits a DecisionTrace of the numbers behind each decision, which the
  coach will later hand to an LLM to narrate. The LLM never does poker maths.

Simulator:
- 2,200-3,400 hands/sec. Deck RNG is separate from bot RNGs so rollout counts
  cannot shift the deal.
- Controlled skill-ladder test asserts the difficulty gradient is monotonic:
  73.9 / 53.9 / 27.6 / -155.4 bb/100 over 50k hands.

Assets: 52 CC0 English-pattern card faces plus generated backs.

Tests: 30 passing (evaluator, table rules, pre-flop chart).

Known open: win-rate magnitudes ~10x realistic and several profiles looser
than their labels. Tuning, not correctness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 04:36:03 -04:00