178 lines
11 KiB
Markdown
178 lines
11 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 :sim:test # fast calibration-policy tests
|
||
./gradlew :sim:run --args="styles" # enforced controlled style experiment
|
||
./gradlew :sim:run --args="calibrate" # enforced 4×100k fixed-pool skill + style calibration
|
||
./gradlew :sim:run --args="short-stacks" # persistent stacks; asserts side-pot/call-clamp coverage
|
||
./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` |
|
||
| `designs/` | Visual direction and interaction references; current table follows 1A |
|
||
| `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.
|
||
The quick 50k run is diagnostic; only `calibrate` is an enforced result.
|
||
Its paired skill comparisons reset equal stacks to isolate skill, then a
|
||
mandatory persistent-stack pass verifies that short-stack pricing is live.
|
||
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.
|
||
6. **Pot odds are the post-flop baseline.** Never apply a blanket implied-odds
|
||
discount: it is categorically wrong on the river, and future value on earlier
|
||
streets must account for future costs and reverse implied odds before it is
|
||
called an advantage. Price a call against `eligiblePot`, not the displayed
|
||
total: a short stack cannot win side-pot chips above its contribution level.
|
||
7. **DecisionTrace is the coach contract.** It records raw pot odds, the actual
|
||
adjusted threshold, every adjustment, intended and chosen actions, and whether
|
||
a skill error changed the decision. Pre-flop chart percentile/range fields are
|
||
structurally separate from post-flop equity fields; inapplicable values are
|
||
null. The production coach explains these values; it does not reconstruct
|
||
hidden bot logic.
|
||
8. **Persist aggregates, not surveillance.** Player history stores the schema
|
||
version, setup preference, session/hand counts, baseline agreement, and stable
|
||
review-category counters. It never stores hole cards, boards, opponents, or
|
||
raw action histories. Storage mutations are serialised because hand completion
|
||
and coach analysis run on different coroutines. History is non-critical: an
|
||
unreadable/newer schema or failed commit falls back to in-memory counters and
|
||
disables persistence for that process rather than crashing or overwriting data.
|
||
9. **Personas can speak, never act.** `PersonaCue` contains only an opponent's
|
||
already-accepted public action and public table totals. It has no cards,
|
||
equity, decision context, legal actions, or mutation callback.
|
||
`PersonaNarrator` returns only nullable text; `PersonaArbiter` rate-limits,
|
||
times out, bounds, and sanitises that text with a deterministic offline voice.
|
||
|
||
## 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 the paired
|
||
4×100k fixed-opponent calibration before accepting a skill change; it computes
|
||
confidence bounds and exits nonzero when the contract fails.
|
||
- Equal starting stacks are deliberate in the paired skill experiment, but they
|
||
cannot exercise side-pot pricing. `calibrate` therefore finishes with 3,000
|
||
persistent-stack hands and fails unless both `eligiblePot != pot` and
|
||
`toCall > stack` occur. `:sim:test` pins the same paths at a fixed seed.
|
||
|
||
## 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: controlled style calibration holds style constant against the same five
|
||
opponents and deal seed. Rock is 10.5% VPIP, Maniac 67.4%; looseness ordering,
|
||
Calling Station passivity (10.0% PFR, 0.27 AF), Maniac aggression, and PFR
|
||
relationships all pass.
|
||
- Skill calibration pairs four 100k-hand seeds. Every candidate occupies the same
|
||
seat against the same fixed opponent pool and deal seed. Advanced and Expert
|
||
are allowed to overlap, but both must beat Intermediate and Intermediate must
|
||
beat Beginner with a positive 95% lower confidence bound. Current lower bounds
|
||
are +24.42, +11.01, and +24.67 bb/100 respectively.
|
||
- Persistent-stack calibration currently reaches 351 decisions with inaccessible
|
||
side-pot chips and stack-clamped calls in 3,000 hands (seed 20260729).
|
||
- Expert differs by mechanism: it alone maintains opponent reads. The aggression
|
||
prior is measured by the controlled neutral TAG experiment (0.231 observed,
|
||
0.22 configured), not selected because it looks plausible.
|
||
|
||
### 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.
|
||
- `TableSnapshot.lastAction` intentionally survives board-transition frames.
|
||
Live status copy may carry it into the next decision only when the event's
|
||
`street` matches the snapshot, or the first flop actor resurrects a pre-flop
|
||
action.
|
||
- `TableSnapshot.actionNumber` is the authoritative identity of `lastAction`.
|
||
Persona output is accepted only beside the exact hand, street, and action
|
||
number that requested it; a late response is dropped rather than shown against
|
||
a newer decision.
|
||
- A completed hand cannot be reduced to one flat winner list. `HandResult`
|
||
retains each `PotAward` in main/side-pot order and only the hands publicly
|
||
revealed at showdown. The result UI reads those authoritative awards, so two
|
||
players winning different pots are never presented as though they tied.
|
||
- Android is playable in Compose. Engine snapshots are paced through a
|
||
rendezvous channel, human decisions are matched by engine-owned tokens, and a
|
||
completed hand stays on screen until the player explicitly starts the next one.
|
||
The portrait table follows the 1A design direction: oval felt, orbiting seats,
|
||
spatial bets, large 2:3 card art, always-visible legal sizing controls, and a
|
||
winner-focused pot/hand breakdown at completion. Face-up cards have fixed
|
||
legibility floors rather than shrinking on short displays; the opponent orbit
|
||
remains above the community-card band, and the terminal modal repeats the
|
||
board beside exact pot awards so dimming the table never hides the explanation.
|
||
- A cash-game session begins at an explicit take-a-seat screen. Opponents may
|
||
auto-reload below the big blind; the human is never silently topped up and must
|
||
explicitly choose "Reload to N & deal" from the completed-hand screen.
|
||
- The deterministic coach is opt-in and post-action only. Its fundamentals
|
||
baseline receives `DecisionOffer`, so it can see the hero's cards and public
|
||
table state but no opponent hole cards. Post-flop equity is explicitly labelled
|
||
as an estimate against unknown random hands; disagreement is "worth reviewing,"
|
||
never declared solver proof of a mistake.
|
||
- Versioned player history survives process restarts in a small aggregate
|
||
`SharedPreferences` store. The setup screen restores the last player/coach
|
||
preference and shows cross-session agreement/review trends only after at least
|
||
ten coached decisions; smaller samples are labelled as insufficient. Schema
|
||
reads go through an explicit migration dispatcher; add the migration branch
|
||
before incrementing `PLAYER_HISTORY_SCHEMA_VERSION`.
|
||
- Opponents have deterministic offline table voices behind the language-only
|
||
persona arbiter. A future network narrator implements the same nullable-text
|
||
interface and inherits its timeout, fallback, output bounds, and stale-action
|
||
checks without gaining access to poker decisions.
|
||
- 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: network-backed LLM narrator. The persona boundary and offline
|
||
fallback are built.
|