950c7ceb57
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>
87 lines
4.2 KiB
Markdown
87 lines
4.2 KiB
Markdown
# 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.
|