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>
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>
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>
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>
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>
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>