Files
holdem_poker/CLAUDE.md
T
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

87 lines
4.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Poker — Texas Hold'em with teachable AI opponents
Kotlin Multiplatform. Ships iOS + Android; Android first (only Android hardware
for physical testing).
## Build
No `java`/`gradle` on PATH — use Android Studio's bundled JDK:
```bash
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew :engine:jvmTest # engine tests (JVM)
./gradlew :engine:testAndroidHostTest # same suite, Android variant
./gradlew :sim:run --args="50000" # simulate 50k hands, print bot stats
./gradlew :app:assembleDebug # build the APK
```
## Layout
| Path | What |
|---|---|
| `engine/src/commonMain/.../core/` | Cards, evaluator, equity, pre-flop chart |
| `engine/src/commonMain/.../bot/` | Skill/style profiles, `MathBot` |
| `engine/src/commonMain/.../game/` | `Table` — betting rounds, side pots, showdown |
| `sim/` | JVM-only headless simulator used to **tune** bot profiles |
| `app/` | Android app: Compose table, `PokerViewModel` |
| `assets/cards/` | 52 CC0 card faces + generated backs (**source of truth**) |
| `tools/generate_card_assets.sh` | Rasterises those SVGs into `app/.../drawable-*` |
`engine` is pure Kotlin with no platform APIs, so `androidTarget()` /
`iosArm64()` slot in without touching `commonMain`.
## Design rules
1. **Poker maths never goes near the LLM.** Difficulty and style are engine-side
EV/frequency calculations — instant, deterministic, testable, offline. The LLM
only narrates numbers the engine already computed (`DecisionTrace`), and adds
persona/table talk.
2. **Skill and style are orthogonal.** `SkillLevel` = how correct decisions are;
`PlayStyle` = bluffing, sandbagging, aggression, tightness. Build the strongest
bot, then inject *controlled error* for lower tiers.
3. **Pre-flop is range-based, not equity-based.** All-in equity overvalues trash
(7-2o has ~35% vs one random hand but is unplayable). `PreflopChart` ranks the
169 starting hands so `looseness` means "plays the top N%".
4. **The simulator is how bots get tuned.** Run it after any bot change; it prints
a controlled skill-ladder test that must stay monotonic.
## Testing notes
- `Table` takes a `CardSource`, so `StackedDeck.of(holes, board)` gives fully
deterministic hands. Use it for any rule test.
- The simulator gives the **deck its own RNG**, separate from each bot's. Never
share one: bots consume RNG proportional to their `equityIterations`, so a
shared stream means changing a profile silently changes the cards dealt.
- `./gradlew :sim:run --args="chart"` dumps the starting-hand ranking.
- Small samples lie. 1,000 hands is not enough to rank profiles — use 50,000+
before believing a gradient.
## Status
- Evaluator: verified exhaustively against published frequencies for all
2,598,960 five-card hands. ~24M evals/sec.
- Engine: chip-conserving. Covered by tests: side pots, uncalled-bet refunds,
action order, malformed agent output, **TDA Rule 47** (incomplete raises do not
reopen betting, but several that cumulatively reach a full raise do), and
**TDA Rule 20** (odd chip to the first winner left of the button).
- Bots: skill gradient **passes** monotonically (85.9 / 79.8 / 17.4 / 183.1
bb/100 at 50k hands).
### Rules invariants that are easy to get wrong
- Reopening betting cannot be a boolean. `Seat.lastActedAtBet` records the bet
level a player last acted at; betting reopens when `currentBet - lastActedAtBet
>= minRaiseSize`. Several short all-ins can reach that together.
- `PreflopChart` percentiles are weighted by **combination counts** (pair 6,
suited 4, offsuit 12, total 1326), so "top 12%" means 12% of *dealt hands*, not
12% of the 169 classes.
- Anything consuming `DecisionContext.history` across hands must key off
`handNumber`. History is cleared each hand, so a size comparison silently drops
events.
- Known-imperfect: win-rate magnitudes are still ~10x realistic, and several
profiles are looser than their labels (the Rock plays ~38% VPIP, should be
~12%). Tuning is the open work.
- Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's
own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build.
- Not built yet: LLM persona layer, opt-in coach, Compose UI.