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