Commit Graph

25 Commits

Author SHA1 Message Date
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 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