# 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, across several seeds — a single seed will happily agree with a wrong conclusion. 5. **A skill parameter must not smuggle in a style change.** Several bugs came from exactly this: `positionAwareness` silently reduced hands played, `potOddsRespect` systematically loosened weak players (which is a *winning* adjustment, so it inverted the gradient), and error direction overwrote style entirely. Skill should change how *well* a decision is made, not how loose or tight the player is. ## 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: profiles play like their labels (Rock 14.5% VPIP against a 12% setting; `ProfileBehaviourTest` asserts this). Win rates are in a plausible range — roughly +20 bb/100 for a strong seat rather than the earlier +113. - **Adjacent top tiers are not separable.** Advanced and Expert sit inside seed-to-seed noise of each other over 100k hands. The simulator therefore asserts each level beats the one *two* tiers below it, which holds on every seed tried; claiming strict adjacent ordering from one seed would be reading noise as signal. ### 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.