commit 479be1f6b9f69121e676c1e669924c2853d9c2d0 Author: Jay Date: Sat Jul 25 04:36:03 2026 -0400 Initial commit: Hold'em engine, bots, and simulation harness 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0be2ad1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties +local.properties + +# Android +*.apk +*.aab +*.ap_ +*.dex +.cxx/ +release/ +captures/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.kotlin/ + +# macOS +.DS_Store + +# Logs / temp +*.log +*.hprof diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b414bcb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# 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 # evaluator + engine tests +./gradlew :sim:run --args="50000" # simulate 50k hands, print bot stats +``` + +## 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 | +| `assets/cards/` | 52 CC0 card faces + generated backs | + +`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; side pots, odd-chip splits, uncalled-bet refunds, and + incomplete (short all-in) raises all covered by tests. +- Bots: skill gradient **passes** monotonically (73.9 / 53.9 / 27.6 / −155.4 + bb/100 at 50k hands). +- 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. diff --git a/assets/cards/10_of_clubs.svg b/assets/cards/10_of_clubs.svg new file mode 100644 index 0000000..f66f403 --- /dev/null +++ b/assets/cards/10_of_clubs.svg @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/10_of_diamonds.svg b/assets/cards/10_of_diamonds.svg new file mode 100644 index 0000000..4df3e66 --- /dev/null +++ b/assets/cards/10_of_diamonds.svg @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/10_of_hearts.svg b/assets/cards/10_of_hearts.svg new file mode 100644 index 0000000..314e2ec --- /dev/null +++ b/assets/cards/10_of_hearts.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/10_of_spades.svg b/assets/cards/10_of_spades.svg new file mode 100644 index 0000000..6547ea8 --- /dev/null +++ b/assets/cards/10_of_spades.svg @@ -0,0 +1,121 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/2_of_clubs.svg b/assets/cards/2_of_clubs.svg new file mode 100644 index 0000000..4497cca --- /dev/null +++ b/assets/cards/2_of_clubs.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/cards/2_of_diamonds.svg b/assets/cards/2_of_diamonds.svg new file mode 100644 index 0000000..f821e74 --- /dev/null +++ b/assets/cards/2_of_diamonds.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/cards/2_of_hearts.svg b/assets/cards/2_of_hearts.svg new file mode 100644 index 0000000..6ad91dc --- /dev/null +++ b/assets/cards/2_of_hearts.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/cards/2_of_spades.svg b/assets/cards/2_of_spades.svg new file mode 100644 index 0000000..288b52c --- /dev/null +++ b/assets/cards/2_of_spades.svg @@ -0,0 +1,69 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/cards/3_of_clubs.svg b/assets/cards/3_of_clubs.svg new file mode 100644 index 0000000..9a0f6b5 --- /dev/null +++ b/assets/cards/3_of_clubs.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/3_of_diamonds.svg b/assets/cards/3_of_diamonds.svg new file mode 100644 index 0000000..7aa0a23 --- /dev/null +++ b/assets/cards/3_of_diamonds.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/3_of_hearts.svg b/assets/cards/3_of_hearts.svg new file mode 100644 index 0000000..5968eb8 --- /dev/null +++ b/assets/cards/3_of_hearts.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/3_of_spades.svg b/assets/cards/3_of_spades.svg new file mode 100644 index 0000000..e89085d --- /dev/null +++ b/assets/cards/3_of_spades.svg @@ -0,0 +1,73 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/4_of_clubs.svg b/assets/cards/4_of_clubs.svg new file mode 100644 index 0000000..51fcc13 --- /dev/null +++ b/assets/cards/4_of_clubs.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/4_of_diamonds.svg b/assets/cards/4_of_diamonds.svg new file mode 100644 index 0000000..4c2a35f --- /dev/null +++ b/assets/cards/4_of_diamonds.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/4_of_hearts.svg b/assets/cards/4_of_hearts.svg new file mode 100644 index 0000000..64aa8af --- /dev/null +++ b/assets/cards/4_of_hearts.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/4_of_spades.svg b/assets/cards/4_of_spades.svg new file mode 100644 index 0000000..c2aa0c4 --- /dev/null +++ b/assets/cards/4_of_spades.svg @@ -0,0 +1,93 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/5_of_clubs.svg b/assets/cards/5_of_clubs.svg new file mode 100644 index 0000000..2a8933f --- /dev/null +++ b/assets/cards/5_of_clubs.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/5_of_diamonds.svg b/assets/cards/5_of_diamonds.svg new file mode 100644 index 0000000..f541fb8 --- /dev/null +++ b/assets/cards/5_of_diamonds.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/5_of_hearts.svg b/assets/cards/5_of_hearts.svg new file mode 100644 index 0000000..ee5f42e --- /dev/null +++ b/assets/cards/5_of_hearts.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/5_of_spades.svg b/assets/cards/5_of_spades.svg new file mode 100644 index 0000000..f462964 --- /dev/null +++ b/assets/cards/5_of_spades.svg @@ -0,0 +1,101 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/6_of_clubs.svg b/assets/cards/6_of_clubs.svg new file mode 100644 index 0000000..ef8053f --- /dev/null +++ b/assets/cards/6_of_clubs.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/6_of_diamonds.svg b/assets/cards/6_of_diamonds.svg new file mode 100644 index 0000000..67e8901 --- /dev/null +++ b/assets/cards/6_of_diamonds.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/6_of_hearts.svg b/assets/cards/6_of_hearts.svg new file mode 100644 index 0000000..960b8a2 --- /dev/null +++ b/assets/cards/6_of_hearts.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/6_of_spades.svg b/assets/cards/6_of_spades.svg new file mode 100644 index 0000000..775f1e0 --- /dev/null +++ b/assets/cards/6_of_spades.svg @@ -0,0 +1,85 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/7_of_clubs.svg b/assets/cards/7_of_clubs.svg new file mode 100644 index 0000000..3056277 --- /dev/null +++ b/assets/cards/7_of_clubs.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/7_of_diamonds.svg b/assets/cards/7_of_diamonds.svg new file mode 100644 index 0000000..335ed07 --- /dev/null +++ b/assets/cards/7_of_diamonds.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/7_of_hearts.svg b/assets/cards/7_of_hearts.svg new file mode 100644 index 0000000..ab636e7 --- /dev/null +++ b/assets/cards/7_of_hearts.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/7_of_spades.svg b/assets/cards/7_of_spades.svg new file mode 100644 index 0000000..8e1e2e6 --- /dev/null +++ b/assets/cards/7_of_spades.svg @@ -0,0 +1,89 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/8_of_clubs.svg b/assets/cards/8_of_clubs.svg new file mode 100644 index 0000000..144771a --- /dev/null +++ b/assets/cards/8_of_clubs.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/8_of_diamonds.svg b/assets/cards/8_of_diamonds.svg new file mode 100644 index 0000000..d710b85 --- /dev/null +++ b/assets/cards/8_of_diamonds.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/8_of_hearts.svg b/assets/cards/8_of_hearts.svg new file mode 100644 index 0000000..16b4d34 --- /dev/null +++ b/assets/cards/8_of_hearts.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/8_of_spades.svg b/assets/cards/8_of_spades.svg new file mode 100644 index 0000000..89d8401 --- /dev/null +++ b/assets/cards/8_of_spades.svg @@ -0,0 +1,93 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/9_of_clubs.svg b/assets/cards/9_of_clubs.svg new file mode 100644 index 0000000..f014643 --- /dev/null +++ b/assets/cards/9_of_clubs.svg @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/9_of_diamonds.svg b/assets/cards/9_of_diamonds.svg new file mode 100644 index 0000000..b0d1f61 --- /dev/null +++ b/assets/cards/9_of_diamonds.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/9_of_hearts.svg b/assets/cards/9_of_hearts.svg new file mode 100644 index 0000000..f5e8174 --- /dev/null +++ b/assets/cards/9_of_hearts.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/9_of_spades.svg b/assets/cards/9_of_spades.svg new file mode 100644 index 0000000..fdbd145 --- /dev/null +++ b/assets/cards/9_of_spades.svg @@ -0,0 +1,97 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/LICENSE.md b/assets/cards/LICENSE.md new file mode 100644 index 0000000..20845d0 --- /dev/null +++ b/assets/cards/LICENSE.md @@ -0,0 +1,35 @@ +# Card Asset Licensing + +**Source:** [Category:SVG English pattern playing cards](https://commons.wikimedia.org/wiki/Category:SVG_English_pattern_playing_cards) on Wikimedia Commons + +**License:** CC0 1.0 Universal (Public Domain Dedication) — + +**Author:** Dmitry Fomin ([User:Dmitry_Fomin](https://commons.wikimedia.org/wiki/User:Dmitry_Fomin)) + +## What this means + +CC0 is a public domain dedication. You may use, modify, and redistribute these +files for any purpose, including commercial games, with **no attribution +required** and no obligation to open-source your own code. Attribution is +retained here as a courtesy, not an obligation. + +## Contents + +53 files, all verified CC0 at download time (2026-07-24): + +- 52 individual card faces (`_of_.svg`), 360 × 540 px each + - Ranks: `2`–`10`, `jack`, `queen`, `king`, `ace` + - Suits: `clubs`, `diamonds`, `hearts`, `spades` +- `playing_cards_deck.svg` — full 52-card sheet (~2.5 MB) + +## Deliberately excluded + +`English pattern playing cards deck PLUS.svg` was **not** downloaded. Unlike the +rest of the category, it is licensed **LGPL**, not CC0 — a copyleft license with +redistribution conditions that are a poor fit for game assets. Everything you +need is covered by the 52 individual cards above. + +## Not included + +There is no **card back** design in this category — you will need to source or +draw one separately. diff --git a/assets/cards/ace_of_clubs.svg b/assets/cards/ace_of_clubs.svg new file mode 100644 index 0000000..1ffe9fd --- /dev/null +++ b/assets/cards/ace_of_clubs.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/cards/ace_of_diamonds.svg b/assets/cards/ace_of_diamonds.svg new file mode 100644 index 0000000..8070a3d --- /dev/null +++ b/assets/cards/ace_of_diamonds.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/cards/ace_of_hearts.svg b/assets/cards/ace_of_hearts.svg new file mode 100644 index 0000000..f0c3e42 --- /dev/null +++ b/assets/cards/ace_of_hearts.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/cards/ace_of_spades.svg b/assets/cards/ace_of_spades.svg new file mode 100644 index 0000000..0317ae3 --- /dev/null +++ b/assets/cards/ace_of_spades.svg @@ -0,0 +1,65 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/cards/back.svg b/assets/cards/back.svg new file mode 100644 index 0000000..d9e3902 --- /dev/null +++ b/assets/cards/back.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/back_blue.svg b/assets/cards/back_blue.svg new file mode 100644 index 0000000..edda6bd --- /dev/null +++ b/assets/cards/back_blue.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/back_red.svg b/assets/cards/back_red.svg new file mode 100644 index 0000000..d9e3902 --- /dev/null +++ b/assets/cards/back_red.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/jack_of_clubs.svg b/assets/cards/jack_of_clubs.svg new file mode 100644 index 0000000..e0eab98 --- /dev/null +++ b/assets/cards/jack_of_clubs.svg @@ -0,0 +1,1686 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/jack_of_diamonds.svg b/assets/cards/jack_of_diamonds.svg new file mode 100644 index 0000000..1df36ed --- /dev/null +++ b/assets/cards/jack_of_diamonds.svg @@ -0,0 +1,1253 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/jack_of_hearts.svg b/assets/cards/jack_of_hearts.svg new file mode 100644 index 0000000..a058669 --- /dev/null +++ b/assets/cards/jack_of_hearts.svg @@ -0,0 +1,1506 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/jack_of_spades.svg b/assets/cards/jack_of_spades.svg new file mode 100644 index 0000000..667e232 --- /dev/null +++ b/assets/cards/jack_of_spades.svg @@ -0,0 +1,1692 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/king_of_clubs.svg b/assets/cards/king_of_clubs.svg new file mode 100644 index 0000000..44308a9 --- /dev/null +++ b/assets/cards/king_of_clubs.svg @@ -0,0 +1,1673 @@ + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/king_of_diamonds.svg b/assets/cards/king_of_diamonds.svg new file mode 100644 index 0000000..fd6d7b8 --- /dev/null +++ b/assets/cards/king_of_diamonds.svg @@ -0,0 +1,1306 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/king_of_hearts.svg b/assets/cards/king_of_hearts.svg new file mode 100644 index 0000000..8c3c345 --- /dev/null +++ b/assets/cards/king_of_hearts.svg @@ -0,0 +1,2214 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/king_of_spades.svg b/assets/cards/king_of_spades.svg new file mode 100644 index 0000000..e598156 --- /dev/null +++ b/assets/cards/king_of_spades.svg @@ -0,0 +1,1245 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/playing_cards_deck.svg b/assets/cards/playing_cards_deck.svg new file mode 100644 index 0000000..0ae331d --- /dev/null +++ b/assets/cards/playing_cards_deck.svg @@ -0,0 +1,28809 @@ + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/queen_of_clubs.svg b/assets/cards/queen_of_clubs.svg new file mode 100644 index 0000000..40f5a96 --- /dev/null +++ b/assets/cards/queen_of_clubs.svg @@ -0,0 +1,1734 @@ + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/queen_of_diamonds.svg b/assets/cards/queen_of_diamonds.svg new file mode 100644 index 0000000..1252425 --- /dev/null +++ b/assets/cards/queen_of_diamonds.svg @@ -0,0 +1,1304 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/queen_of_hearts.svg b/assets/cards/queen_of_hearts.svg new file mode 100644 index 0000000..a370441 --- /dev/null +++ b/assets/cards/queen_of_hearts.svg @@ -0,0 +1,1741 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/cards/queen_of_spades.svg b/assets/cards/queen_of_spades.svg new file mode 100644 index 0000000..7dcd3ec --- /dev/null +++ b/assets/cards/queen_of_spades.svg @@ -0,0 +1,1562 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8996d45 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + kotlin("multiplatform") version "2.2.10" apply false + kotlin("jvm") version "2.2.10" apply false +} diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts new file mode 100644 index 0000000..1652c08 --- /dev/null +++ b/engine/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + kotlin("multiplatform") +} + +kotlin { + // JVM target drives tests and the headless simulator today. + // androidTarget() / iosArm64() slot in here later without touching commonMain. + jvm() + + sourceSets { + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt new file mode 100644 index 0000000..fc845af --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt @@ -0,0 +1,257 @@ +package com.jsjdesigns.poker.bot + +import com.jsjdesigns.poker.core.Equity +import com.jsjdesigns.poker.core.PreflopChart +import com.jsjdesigns.poker.game.Action +import com.jsjdesigns.poker.game.ActionType +import com.jsjdesigns.poker.game.DecisionContext +import com.jsjdesigns.poker.game.PlayerAgent +import com.jsjdesigns.poker.game.Street +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * The numbers behind one decision. + * + * Kept deliberately explicit because this is exactly what the coach hands to the + * LLM. The model narrates these values; it never computes them. + */ +data class DecisionTrace( + val equity: Double, + val breakEvenEquity: Double, + val potOdds: String, + val chosen: Action, + val reason: String, +) + +/** + * A bot that decides from equity and pot odds, then distorts that decision through + * its [SkillLevel] and [PlayStyle]. + * + * Everything here is deterministic given the seed, runs in well under a + * millisecond, and needs no network — the LLM layer sits *on top* of this, never + * inside it. + */ +class MathBot( + val profile: BotProfile, + private val random: Random = Random.Default, +) : PlayerAgent { + + val mood = BotMood(profile.style.tiltSusceptibility) + val reads = OpponentModel() + + var lastTrace: DecisionTrace? = null + private set + + override fun act(ctx: DecisionContext): Action { + val skill = profile.skill + val style = profile.style + val opponents = ctx.activeOpponents.coerceAtLeast(1) + + if (skill.readsOpponents) reads.observe(ctx.history) + if (ctx.street == Street.PREFLOP) return actPreflop(ctx) + + // Weaker players run fewer rollouts, so they genuinely misjudge their hand + // rather than playing well and then blundering at random. + val equity = Equity.estimate( + hole = ctx.hole, + board = ctx.board, + opponents = opponents, + iterations = skill.equityIterations, + random = random, + ) + + val looseness = (style.looseness + mood.loosenessBonus()).coerceIn(0.0, 1.0) + val aggression = (style.aggression + mood.aggressionBonus()).coerceIn(0.0, 1.0) + + val breakEven = Equity.potOdds(ctx.pot, ctx.toCall) + + // Discipline: experts use the true break-even point; weak players drift + // toward calling regardless of price. + val discipline = skill.potOddsRespect + var bar = breakEven * discipline + breakEven * (1 - discipline) * 0.45 + bar *= (1.0 - looseness * 0.35) + + // Position is worth real equity, and better players know it. + bar *= if (ctx.inPosition) 1.0 - 0.12 * skill.positionAwareness + else 1.0 + 0.10 * skill.positionAwareness + + // Exploitation: a habitual bettor's bet means less, so call wider against + // them; a passive player's bet means strength, so fold more. + if (skill.readsOpponents && ctx.toCall > 0) { + val bettor = ctx.history.lastOrNull { + it.action.type == ActionType.BET || it.action.type == ActionType.RAISE + }?.seat + if (bettor != null && bettor != ctx.seat.index && reads.actionsObserved(bettor) >= 25) { + bar *= (1.0 - (reads.aggressionRate(bettor) - 0.5) * 0.50).coerceIn(0.6, 1.4) + } + } + + val raiseBar = (0.62 - aggression * 0.22).coerceIn(0.30, 0.75) + + var decision = decide(ctx, equity, bar, raiseBar, aggression, style, opponents) + + // Outright mistakes, on top of misjudgement. + if (random.nextDouble() < skill.errorRate) { + decision = blunder(ctx, decision) + } + + lastTrace = DecisionTrace( + equity = equity, + breakEvenEquity = breakEven, + potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call", + chosen = decision, + reason = traceReason(equity, breakEven, ctx), + ) + return decision + } + + /** + * Pre-flop is range-based rather than equity-based: real players think "I open + * the top N%", so [PlayStyle.looseness] sets N directly and a Rock at 0.12 + * genuinely plays 12% of hands. + */ + private fun actPreflop(ctx: DecisionContext): Action { + val skill = profile.skill + val style = profile.style + val pct = PreflopChart.percentile(ctx.hole) + + val looseness = (style.looseness + mood.loosenessBonus()).coerceIn(0.02, 1.0) + val aggression = (style.aggression + mood.aggressionBonus()).coerceIn(0.0, 1.0) + + // Undisciplined players simply play too many hands. This is the skill axis + // acting on range width, kept separate from the style axis above. + val sloppiness = 1.0 + (1.0 - skill.potOddsRespect) * 0.70 + val positional = if (ctx.inPosition) 1.0 + 0.45 * skill.positionAwareness + else 1.0 - 0.25 * skill.positionAwareness + + val facingRaise = ctx.toCall > ctx.bigBlind + var gate = looseness * sloppiness * positional + if (facingRaise) gate *= 0.45 + gate = gate.coerceIn(0.01, 1.0) + + val raiseGate = gate * (0.30 + aggression * 0.45) + + val action = when { + pct <= raiseGate && ctx.canRaise -> + Action(ActionType.RAISE, preflopRaiseTo(ctx, facingRaise)) + pct <= gate -> + if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall) + else -> + if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD) + } + + val final = if (random.nextDouble() < skill.errorRate) blunder(ctx, action) else action + lastTrace = DecisionTrace( + equity = 1.0 - pct, + breakEvenEquity = Equity.potOdds(ctx.pot, ctx.toCall), + potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call", + chosen = final, + reason = "Starting hand is in the top ${(pct * 100).roundToInt()}% " + + "and this profile plays about the top ${(gate * 100).roundToInt()}%.", + ) + return final + } + + private fun preflopRaiseTo(ctx: DecisionContext, facingRaise: Boolean): Int { + if (ctx.maxRaiseTo <= ctx.minRaiseTo) return ctx.maxRaiseTo + val desired = if (facingRaise) ctx.minRaiseTo + (ctx.pot * 0.40).roundToInt() + else ctx.bigBlind * 3 + return desired.coerceIn(ctx.minRaiseTo, ctx.maxRaiseTo) + } + + private fun decide( + ctx: DecisionContext, + equity: Double, + bar: Double, + raiseBar: Double, + aggression: Double, + style: PlayStyle, + opponents: Int, + ): Action { + val strong = equity >= raiseBar + val monster = equity >= 0.82 + + // Sandbagging: under-represent a monster to keep them in. + if (monster && random.nextDouble() < style.slowplayFrequency && ctx.street != Street.RIVER) { + return if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall) + } + + if (ctx.canCheck) { + // Continuation bet: having taken the lead pre-flop, fire again on the + // flop regardless of what it brought. + val tookPreflopLead = ctx.history.lastOrNull { + it.street == Street.PREFLOP && + (it.action.type == ActionType.BET || it.action.type == ActionType.RAISE) + }?.seat == ctx.seat.index + if (tookPreflopLead && ctx.street == Street.FLOP && + random.nextDouble() < style.contBetFrequency + ) { + return Action(ActionType.BET, sizeBet(ctx, equity, aggression)) + } + + // No bet to face: either take the lead or check behind. + val bluffing = equity < 0.35 && + random.nextDouble() < style.bluffFrequency / opponents.coerceAtLeast(1) + if (strong || bluffing) { + if (random.nextDouble() < aggression || bluffing) { + return Action(ActionType.BET, sizeBet(ctx, equity, aggression)) + } + } + return Action(ActionType.CHECK) + } + + // Facing a bet. + if (equity < bar) { + // Bluff-raising with nothing, occasionally, when heads-up. + if (opponents == 1 && equity > 0.18 && + random.nextDouble() < style.bluffFrequency * 0.5 && ctx.canRaise + ) { + return Action(ActionType.RAISE, sizeBet(ctx, equity, aggression)) + } + return Action(ActionType.FOLD) + } + + if (strong && ctx.canRaise && random.nextDouble() < aggression) { + return Action(ActionType.RAISE, sizeBet(ctx, equity, aggression)) + } + + return Action(ActionType.CALL, ctx.toCall) + } + + /** Pot-fraction sizing, widening with equity and aggression. */ + private fun sizeBet(ctx: DecisionContext, equity: Double, aggression: Double): Int { + val fraction = when { + equity > 0.85 -> 0.75 + aggression * 0.45 + equity > 0.65 -> 0.55 + aggression * 0.30 + equity > 0.45 -> 0.45 + aggression * 0.20 + else -> 0.40 + aggression * 0.25 // bluff sizing + } + val target = ctx.committedThisRoundPlus(((ctx.pot * fraction).roundToInt())) + // A short stack that cannot afford a full min-raise may still shove; that + // is legal, it simply does not reopen the betting. + if (ctx.maxRaiseTo <= ctx.minRaiseTo) return ctx.maxRaiseTo + return target.coerceIn(ctx.minRaiseTo, ctx.maxRaiseTo) + } + + private fun blunder(ctx: DecisionContext, intended: Action): Action = when (intended.type) { + ActionType.FOLD -> if (ctx.toCall > 0) Action(ActionType.CALL, ctx.toCall) else Action(ActionType.CHECK) + ActionType.CHECK -> Action(ActionType.CHECK) + ActionType.CALL -> if (random.nextBoolean() && ctx.toCall > 0) Action(ActionType.FOLD) else intended + ActionType.BET, ActionType.RAISE -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall) + } + + private fun traceReason(equity: Double, breakEven: Double, ctx: DecisionContext): String { + val pct = (equity * 100).roundToInt() + return if (ctx.toCall > 0) { + val need = (breakEven * 100).roundToInt() + "About $pct% equity against ${ctx.activeOpponents} opponent(s); needed $need% to call profitably." + } else { + "About $pct% equity against ${ctx.activeOpponents} opponent(s), no bet to face." + } + } +} + +/** Converts a pot-fraction bet into a raise-to figure. */ +private fun DecisionContext.committedThisRoundPlus(extra: Int): Int = + seat.committedThisRound + toCall + extra diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/OpponentModel.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/OpponentModel.kt new file mode 100644 index 0000000..0a42bad --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/OpponentModel.kt @@ -0,0 +1,44 @@ +package com.jsjdesigns.poker.bot + +import com.jsjdesigns.poker.game.ActionType +import com.jsjdesigns.poker.game.HandEvent + +/** + * A running read on how aggressive each opponent is. + * + * Only consulted by skill levels with [SkillLevel.readsOpponents] set, which is + * what separates a player who merely plays their own cards well from one who + * adjusts to the table. + */ +class OpponentModel { + + private val aggressiveActions = HashMap() + private val totalActions = HashMap() + private var consumed = 0 + + /** Folds in any events not yet seen. History resets each hand, so detect that. */ + fun observe(history: List) { + if (history.size < consumed) consumed = 0 + while (consumed < history.size) { + val e = history[consumed++] + totalActions[e.seat] = (totalActions[e.seat] ?: 0) + 1 + if (e.action.type == ActionType.BET || e.action.type == ActionType.RAISE) { + aggressiveActions[e.seat] = (aggressiveActions[e.seat] ?: 0) + 1 + } + } + } + + /** Share of this opponent's actions that were bets or raises; 0.5 until sampled. */ + fun aggressionRate(seat: Int): Double { + val total = totalActions[seat] ?: 0 + if (total < MIN_SAMPLE) return 0.5 + return (aggressiveActions[seat] ?: 0).toDouble() / total + } + + fun actionsObserved(seat: Int): Int = totalActions[seat] ?: 0 + + private companion object { + /** Below this, a read is noise rather than information. */ + const val MIN_SAMPLE = 25 + } +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/Profiles.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/Profiles.kt new file mode 100644 index 0000000..f8fed57 --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/Profiles.kt @@ -0,0 +1,104 @@ +package com.jsjdesigns.poker.bot + +/** + * How *correct* a player's decisions are. Independent of [PlayStyle]. + * + * The interesting lever here is [equityIterations]. Rather than making weak bots + * flip coins, we give them a noisier estimate of their own hand strength — so a + * beginner genuinely *misjudges* a hand the way a human does, instead of playing + * well and then randomly blundering. [errorRate] is a smaller, separate effect for + * outright mistakes. + */ +enum class SkillLevel( + val label: String, + val equityIterations: Int, + val errorRate: Double, + val potOddsRespect: Double, + val positionAwareness: Double, + val readsOpponents: Boolean, +) { + BEGINNER("Beginner", equityIterations = 120, errorRate = 0.30, potOddsRespect = 0.20, positionAwareness = 0.10, readsOpponents = false), + INTERMEDIATE("Intermediate", equityIterations = 600, errorRate = 0.14, potOddsRespect = 0.60, positionAwareness = 0.45, readsOpponents = false), + ADVANCED("Advanced", equityIterations = 1500, errorRate = 0.05, potOddsRespect = 0.88, positionAwareness = 0.80, readsOpponents = true), + EXPERT("Expert", equityIterations = 3000, errorRate = 0.015, potOddsRespect = 1.00, positionAwareness = 1.00, readsOpponents = true), +} + +/** + * *How* a player plays, independent of how well. A beginner and an expert can both + * be maniacs; they will be wildly different opponents. + * + * All values are 0..1 frequencies or weights. + */ +data class PlayStyle( + val label: String, + /** Preference for betting/raising over calling. */ + val aggression: Double, + /** How wide a range they enter pots with. */ + val looseness: Double, + /** How often they fire with little or no equity. */ + val bluffFrequency: Double, + /** Sandbagging: how often they under-represent a strong hand to induce. */ + val slowplayFrequency: Double, + /** How often they continuation-bet after taking the lead pre-flop. */ + val contBetFrequency: Double, + /** How much a bad beat destabilises them, feeding the tilt model. */ + val tiltSusceptibility: Double, +) { + companion object { + val ROCK = PlayStyle("Rock", aggression = 0.30, looseness = 0.12, bluffFrequency = 0.04, slowplayFrequency = 0.15, contBetFrequency = 0.40, tiltSusceptibility = 0.15) + val TIGHT_AGGRESSIVE = PlayStyle("Tight-Aggressive", aggression = 0.72, looseness = 0.22, bluffFrequency = 0.18, slowplayFrequency = 0.12, contBetFrequency = 0.70, tiltSusceptibility = 0.30) + val LOOSE_AGGRESSIVE = PlayStyle("Loose-Aggressive", aggression = 0.82, looseness = 0.45, bluffFrequency = 0.32, slowplayFrequency = 0.10, contBetFrequency = 0.78, tiltSusceptibility = 0.45) + val CALLING_STATION = PlayStyle("Calling Station", aggression = 0.12, looseness = 0.62, bluffFrequency = 0.03, slowplayFrequency = 0.30, contBetFrequency = 0.20, tiltSusceptibility = 0.25) + val MANIAC = PlayStyle("Maniac", aggression = 0.95, looseness = 0.75, bluffFrequency = 0.50, slowplayFrequency = 0.05, contBetFrequency = 0.88, tiltSusceptibility = 0.70) + val TRAPPER = PlayStyle("Trapper", aggression = 0.40, looseness = 0.28, bluffFrequency = 0.10, slowplayFrequency = 0.55, contBetFrequency = 0.35, tiltSusceptibility = 0.20) + + val ALL = listOf(ROCK, TIGHT_AGGRESSIVE, LOOSE_AGGRESSIVE, CALLING_STATION, MANIAC, TRAPPER) + } +} + +/** + * A named opponent: the crossing of a skill level with a style, plus the mutable + * emotional state that makes them feel like a person across a session. + */ +data class BotProfile( + val name: String, + val skill: SkillLevel, + val style: PlayStyle, + /** Short character note; later fed to the LLM for voice and table talk. */ + val persona: String = "", +) { + val description: String get() = "${skill.label} ${style.label}" +} + +/** + * Emotional state that persists between hands and decays back toward baseline. + * Tilt widens a player's range and inflates their aggression — the same way it + * does in a real game. + */ +class BotMood(private val susceptibility: Double) { + + /** -1 (rattled/tilted) .. +1 (running over the table). */ + var tilt: Double = 0.0 + private set + + fun recordLoss(potBigBlinds: Double, wasBadBeat: Boolean) { + val sting = (potBigBlinds / 40.0).coerceAtMost(1.0) * susceptibility + tilt -= if (wasBadBeat) sting * 1.8 else sting + tilt = tilt.coerceIn(-1.0, 1.0) + } + + fun recordWin(potBigBlinds: Double) { + tilt += (potBigBlinds / 60.0).coerceAtMost(1.0) * susceptibility * 0.6 + tilt = tilt.coerceIn(-1.0, 1.0) + } + + /** Called once per hand; mood fades rather than lasting forever. */ + fun decay() { + tilt *= 0.90 + if (tilt in -0.01..0.01) tilt = 0.0 + } + + /** Tilted players play looser and more aggressively, in both directions. */ + fun loosenessBonus(): Double = if (tilt < 0) -tilt * 0.35 else tilt * 0.12 + fun aggressionBonus(): Double = if (tilt < 0) -tilt * 0.30 else tilt * 0.18 +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Card.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Card.kt new file mode 100644 index 0000000..302a02d --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Card.kt @@ -0,0 +1,75 @@ +package com.jsjdesigns.poker.core + +enum class Suit(val symbol: Char, val assetName: String) { + CLUBS('c', "clubs"), + DIAMONDS('d', "diamonds"), + HEARTS('h', "hearts"), + SPADES('s', "spades"); + + val isRed: Boolean get() = this == DIAMONDS || this == HEARTS +} + +/** + * A card as a single Int in 0..51. + * + * Encoding is `(rank - 2) * 4 + suit.ordinal`, which keeps ranks contiguous so the + * evaluator can bucket by rank with plain array indexing and no branching. + * Ranks run 2..14 with 14 = ace. + */ +@JvmInline +value class Card(val index: Int) { + + val rank: Int get() = 2 + index / 4 + val suit: Suit get() = Suit.entries[index % 4] + + /** Filename in `assets/cards/`, e.g. `ace_of_spades.svg`. */ + val assetName: String get() = "${rankAssetName(rank)}_of_${suit.assetName}.svg" + + /** Compact form used in logs and tests, e.g. `Ah`, `Td`, `2c`. */ + override fun toString(): String = "${rankSymbol(rank)}${suit.symbol}" + + companion object { + const val DECK_SIZE = 52 + + fun of(rank: Int, suit: Suit): Card { + require(rank in 2..14) { "rank out of range: $rank" } + return Card((rank - 2) * 4 + suit.ordinal) + } + + /** Parses `Ah`, `td`, `10c`, `2S`. */ + fun parse(text: String): Card { + val s = text.trim() + require(s.length >= 2) { "unparseable card: '$text'" } + val suitChar = s.last().lowercaseChar() + val suit = Suit.entries.firstOrNull { it.symbol == suitChar } + ?: throw IllegalArgumentException("unknown suit in '$text'") + val rankPart = s.dropLast(1) + val rank = when (rankPart.uppercase()) { + "A" -> 14 + "K" -> 13 + "Q" -> 12 + "J" -> 11 + "T", "10" -> 10 + else -> rankPart.toIntOrNull() + ?: throw IllegalArgumentException("unknown rank in '$text'") + } + return of(rank, suit) + } + + fun rankSymbol(rank: Int): String = when (rank) { + 14 -> "A"; 13 -> "K"; 12 -> "Q"; 11 -> "J"; 10 -> "T" + else -> rank.toString() + } + + fun rankAssetName(rank: Int): String = when (rank) { + 14 -> "ace"; 13 -> "king"; 12 -> "queen"; 11 -> "jack" + else -> rank.toString() + } + } +} + +/** Parses a space-separated list such as `"Ah Kd 7c"`. */ +fun cardsOf(text: String): IntArray = + text.split(' ', ',').filter { it.isNotBlank() }.map { Card.parse(it).index }.toIntArray() + +fun IntArray.cardsToString(): String = joinToString(" ") { Card(it).toString() } diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/CardSource.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/CardSource.kt new file mode 100644 index 0000000..16d38b2 --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/CardSource.kt @@ -0,0 +1,55 @@ +package com.jsjdesigns.poker.core + +/** + * Where a table gets its cards. + * + * Abstracted so tests can stack the deck deterministically, and so hand replay + * can later re-deal a recorded hand exactly. + */ +interface CardSource { + fun shuffle() + fun deal(): Int + fun deal(count: Int): IntArray = IntArray(count) { deal() } +} + +/** + * A fixed deal order, for tests and replays. + * + * Deal order matches [com.jsjdesigns.poker.game.Table]: two hole cards per seat in seat + * order, then burn + flop, burn + turn, burn + river. + */ +class StackedDeck(private val order: IntArray) : CardSource { + private var next = 0 + override fun shuffle() { next = 0 } + override fun deal(): Int { + check(next < order.size) { "stacked deck exhausted after $next cards" } + return order[next++] + } + + companion object { + /** Builds a stacked deck from readable text, e.g. `holes = listOf("Ah Ad", "Kc Ks")`. */ + fun of(holes: List, board: String = "", filler: String = ""): StackedDeck { + val cards = ArrayList() + for (h in holes) cards.addAll(cardsOf(h).toList()) + val boardCards = if (board.isBlank()) IntArray(0) else cardsOf(board) + val fillerCards = if (filler.isBlank()) IntArray(0) else cardsOf(filler) + + // burn + flop, burn + turn, burn + river + val used = (cards + boardCards.toList()).toSet() + val burns = (0 until Card.DECK_SIZE).filter { it !in used }.iterator() + fun burn(): Int = burns.next() + + if (boardCards.isNotEmpty()) { + cards.add(burn()) + for (i in 0 until minOf(3, boardCards.size)) cards.add(boardCards[i]) + if (boardCards.size > 3) { cards.add(burn()); cards.add(boardCards[3]) } + if (boardCards.size > 4) { cards.add(burn()); cards.add(boardCards[4]) } + } + cards.addAll(fillerCards.toList()) + // Pad with whatever is left so the deck never runs dry mid-hand. + val chosen = cards.toSet() + for (c in 0 until Card.DECK_SIZE) if (c !in chosen) cards.add(c) + return StackedDeck(cards.toIntArray()) + } + } +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Deck.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Deck.kt new file mode 100644 index 0000000..d085435 --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Deck.kt @@ -0,0 +1,31 @@ +package com.jsjdesigns.poker.core + +import kotlin.random.Random + +/** + * A shuffled 52-card deck. Seedable via [random] so any hand the bots misplay can + * be replayed exactly — which matters a lot when tuning profiles. + */ +class Deck(private val random: Random = Random.Default) : CardSource { + + private val cards = IntArray(Card.DECK_SIZE) { it } + private var next = 0 + + val remaining: Int get() = Card.DECK_SIZE - next + + override fun shuffle() { + for (i in cards.indices) cards[i] = i + for (i in Card.DECK_SIZE - 1 downTo 1) { + val j = random.nextInt(i + 1) + val tmp = cards[i]; cards[i] = cards[j]; cards[j] = tmp + } + next = 0 + } + + override fun deal(): Int { + check(next < Card.DECK_SIZE) { "deck exhausted" } + return cards[next++] + } + + override fun deal(count: Int): IntArray = IntArray(count) { deal() } +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Equity.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Equity.kt new file mode 100644 index 0000000..6dc69cf --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Equity.kt @@ -0,0 +1,93 @@ +package com.jsjdesigns.poker.core + +import kotlin.random.Random + +/** + * Monte Carlo equity: the share of the pot a hand wins on average against random + * opposition, with ties split. + * + * This is the number every bot decision is built on, and the number the coach + * explains to the player. The LLM is never asked to compute it. + */ +object Equity { + + /** + * @param hole the two hole cards + * @param board 0, 3, 4 or 5 community cards + * @param opponents how many opponents are still live + * @param iterations rollouts to run; 2000 is accurate to roughly +/-1% + */ + fun estimate( + hole: IntArray, + board: IntArray, + opponents: Int, + iterations: Int = 2000, + random: Random = Random.Default, + ): Double { + require(hole.size == 2) { "expected 2 hole cards, got ${hole.size}" } + require(board.size <= 5) { "board too large: ${board.size}" } + require(opponents >= 1) { "need at least one opponent" } + + val known = BooleanArray(Card.DECK_SIZE) + for (c in hole) known[c] = true + for (c in board) known[c] = true + + val deck = IntArray(Card.DECK_SIZE - hole.size - board.size) + var n = 0 + for (c in 0 until Card.DECK_SIZE) if (!known[c]) deck[n++] = c + + val boardNeeded = 5 - board.size + val draws = boardNeeded + opponents * 2 + check(draws <= deck.size) { "not enough cards left to simulate" } + + // hero = [hole0, hole1, board0..board4] + val hero = IntArray(7) + hero[0] = hole[0] + hero[1] = hole[1] + for (i in board.indices) hero[2 + i] = board[i] + + val opp = IntArray(7) + val boardFillStart = 2 + board.size + var won = 0.0 + + repeat(iterations) { + // Partial Fisher-Yates: only shuffle the cards we actually draw. + for (i in 0 until draws) { + val j = i + random.nextInt(deck.size - i) + val tmp = deck[i]; deck[i] = deck[j]; deck[j] = tmp + } + + var idx = 0 + for (i in 0 until boardNeeded) hero[boardFillStart + i] = deck[idx++] + + val heroScore = HandEvaluator.evaluate(hero, 7) + for (i in 2..6) opp[i] = hero[i] + + var bestOpp = -1 + var tiedAtBest = 0 + for (o in 0 until opponents) { + opp[0] = deck[idx++] + opp[1] = deck[idx++] + val s = HandEvaluator.evaluate(opp, 7) + if (s > bestOpp) { + bestOpp = s + tiedAtBest = 1 + } else if (s == bestOpp) { + tiedAtBest++ + } + } + + if (heroScore > bestOpp) won += 1.0 + else if (heroScore == bestOpp) won += 1.0 / (tiedAtBest + 1) + } + + return won / iterations + } + + /** + * Pot odds as a break-even equity: call [toCall] to win [pot], and you need at + * least this much equity for the call to show a profit. + */ + fun potOdds(pot: Int, toCall: Int): Double = + if (toCall <= 0) 0.0 else toCall.toDouble() / (pot + toCall) +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/HandEvaluator.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/HandEvaluator.kt new file mode 100644 index 0000000..346c061 --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/HandEvaluator.kt @@ -0,0 +1,157 @@ +package com.jsjdesigns.poker.core + +/** + * Five-to-seven card hand evaluation. + * + * [evaluate] returns a packed Int where a numerically larger value is a strictly + * better hand, so comparing hands is a plain `>`. Layout is + * `category(4 bits) | t1 | t2 | t3 | t4 | t5` with each tiebreak nibble holding a + * rank in 2..14. + * + * This deliberately avoids the "try all 21 five-card subsets" approach: equity + * simulation calls this millions of times, so it buckets ranks and suits in a + * single pass instead. + */ +object HandEvaluator { + + const val HIGH_CARD = 0 + const val PAIR = 1 + const val TWO_PAIR = 2 + const val TRIPS = 3 + const val STRAIGHT = 4 + const val FLUSH = 5 + const val FULL_HOUSE = 6 + const val QUADS = 7 + const val STRAIGHT_FLUSH = 8 + + val CATEGORY_NAMES = arrayOf( + "High Card", "Pair", "Two Pair", "Three of a Kind", "Straight", + "Flush", "Full House", "Four of a Kind", "Straight Flush", + ) + + fun categoryOf(score: Int): Int = score ushr 20 + + fun describe(score: Int): String = CATEGORY_NAMES[categoryOf(score)] + + /** Evaluates 5, 6 or 7 cards given as deck indices in 0..51. */ + fun evaluate(cards: IntArray, count: Int = cards.size): Int { + val rankCount = IntArray(15) + val suitCount = IntArray(4) + val suitMask = IntArray(4) + var rankMask = 0 + + for (i in 0 until count) { + val c = cards[i] + val r = 2 + c / 4 + val s = c % 4 + rankCount[r]++ + suitCount[s]++ + suitMask[s] = suitMask[s] or (1 shl r) + rankMask = rankMask or (1 shl r) + } + + // Flushes dominate everything below a full house, so resolve them first. + var flushSuit = -1 + for (s in 0..3) if (suitCount[s] >= 5) flushSuit = s + + if (flushSuit >= 0) { + val fm = suitMask[flushSuit] + val sfHigh = straightHigh(withWheel(fm)) + if (sfHigh > 0) return pack(STRAIGHT_FLUSH, sfHigh) + return packTop5(FLUSH, fm) + } + + // Quads and full houses outrank a straight, so count-based hands come next. + var quad = 0 + var tripsHigh = 0 + var tripsLow = 0 + var pairHigh = 0 + var pairLow = 0 + for (r in 14 downTo 2) { + when (rankCount[r]) { + 4 -> if (quad == 0) quad = r + 3 -> if (tripsHigh == 0) tripsHigh = r else if (tripsLow == 0) tripsLow = r + 2 -> if (pairHigh == 0) pairHigh = r else if (pairLow == 0) pairLow = r + } + } + + if (quad != 0) { + val kicker = highestExcluding(rankMask, quad) + return pack(QUADS, quad, kicker) + } + + if (tripsHigh != 0 && (tripsLow != 0 || pairHigh != 0)) { + // A second set plays as the pair when it beats the best actual pair. + val pair = if (tripsLow > pairHigh) tripsLow else pairHigh + return pack(FULL_HOUSE, tripsHigh, pair) + } + + val straight = straightHigh(withWheel(rankMask)) + if (straight > 0) return pack(STRAIGHT, straight) + + if (tripsHigh != 0) { + val k1 = highestExcluding(rankMask, tripsHigh) + val k2 = highestExcluding(rankMask, tripsHigh, k1) + return pack(TRIPS, tripsHigh, k1, k2) + } + + if (pairHigh != 0 && pairLow != 0) { + val kicker = highestExcluding(rankMask, pairHigh, pairLow) + return pack(TWO_PAIR, pairHigh, pairLow, kicker) + } + + if (pairHigh != 0) { + val k1 = highestExcluding(rankMask, pairHigh) + val k2 = highestExcluding(rankMask, pairHigh, k1) + val k3 = highestExcluding(rankMask, pairHigh, k1, k2) + return pack(PAIR, pairHigh, k1, k2, k3) + } + + return packTop5(HIGH_CARD, rankMask) + } + + /** Mirrors the ace into the low slot so A-2-3-4-5 registers as a straight. */ + private fun withWheel(mask: Int): Int = + if (mask and (1 shl 14) != 0) mask or (1 shl 1) else mask + + /** Highest top-card of any five-in-a-row present in [mask], or 0. */ + private fun straightHigh(mask: Int): Int { + for (high in 14 downTo 5) { + val need = 0b11111 shl (high - 4) + if (mask and need == need) return high + } + return 0 + } + + private fun highestExcluding(mask: Int, vararg exclude: Int): Int { + var m = mask + for (e in exclude) m = m and (1 shl e).inv() + for (r in 14 downTo 2) if (m and (1 shl r) != 0) return r + return 0 + } + + private fun pack(category: Int, vararg tiebreaks: Int): Int { + var s = category + for (i in 0 until 5) { + s = (s shl 4) or (if (i < tiebreaks.size) tiebreaks[i] else 0) + } + return s + } + + private fun packTop5(category: Int, mask: Int): Int { + var s = category + var taken = 0 + for (r in 14 downTo 2) { + if (taken == 5) break + if (mask and (1 shl r) != 0) { + s = (s shl 4) or r + taken++ + } + } + while (taken < 5) { + s = s shl 4 + taken++ + } + return s + } +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/PreflopChart.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/PreflopChart.kt new file mode 100644 index 0000000..a3e778e --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/PreflopChart.kt @@ -0,0 +1,140 @@ +package com.jsjdesigns.poker.core + +import kotlin.random.Random + +/** + * Strength ranking of the 169 distinct starting hands. + * + * Two separate problems have to be solved here, and it is worth keeping them + * distinct: + * + * 1. **The threshold.** Raw all-in equity must never be compared against pot odds + * pre-flop. 7-2o has ~35% equity against one random hand but is unplayable, + * because you never realise that equity across three streets. Solved by + * ranking hands and gating on percentile — "I open the top 15%". + * + * 2. **The ordering.** All-in equity is still a flawed way to *rank* hands: it + * undervalues suited connectors, whose worth is in implied odds, and + * overvalues weak aces and small pairs, which are dominated or hard to play. + * So the raw equity is adjusted by an explicit playability term below. + * + * The adjustment is a documented heuristic in the spirit of the Chen formula, not + * solver output. It is a reasonable starting ordering to be tuned against the + * simulator, not a claim of correctness. + * + * Computed once on first use (~50ms) and cached. + */ +object PreflopChart { + + /** 169 entries keyed by [key]; value is percentile where 0.0 is the best hand. */ + private val percentiles: DoubleArray by lazy { build() } + + /** Canonical slot for a starting hand: pairs, then suited, then offsuit. */ + fun key(hole: IntArray): Int { + val r1 = 2 + hole[0] / 4 + val r2 = 2 + hole[1] / 4 + val suited = hole[0] % 4 == hole[1] % 4 + val hi = maxOf(r1, r2) - 2 + val lo = minOf(r1, r2) - 2 + return when { + hi == lo -> hi // 0..12 pairs + suited -> 13 + hi * 13 + lo // suited + else -> 13 + 169 + hi * 13 + lo // offsuit + } + } + + private const val TABLE_SIZE = 13 + 169 + 169 + + /** + * Percentile of this starting hand, 0.0 (aces) to 1.0 (worst). + * A player who "plays the top 20%" enters when this is <= 0.20. + */ + fun percentile(hole: IntArray): Double = percentiles[key(hole)] + + /** + * Playability adjustment applied on top of all-in equity, in equity points. + * + * Captures what raw equity cannot: implied odds for hands that make disguised + * straights and flushes, and reverse implied odds for hands that are usually + * dominated when they connect. + */ + private fun playability(hi: Int, lo: Int, suited: Boolean, pair: Boolean): Double { + var adj = 0.0 + + if (pair) { + // Small pairs have big all-in equity but need to flop a set to continue. + if (hi <= 6) adj -= 0.030 + else if (hi <= 9) adj -= 0.012 + return adj + } + + // Flush potential is worth real money postflop. + if (suited) adj += 0.035 + + // Connectedness: straight potential falls away fast as the gap widens. + val gap = hi - lo - 1 + adj += when (gap) { + 0 -> 0.022 + 1 -> 0.012 + 2 -> 0.004 + else -> -0.004 * gap + } + + // Two broadway cards dominate rather than being dominated. + if (lo >= 10) adj += 0.020 + + // Weak aces flop top pair with a hopeless kicker. + if (hi == 14 && lo <= 9) adj -= if (suited) 0.018 else 0.034 + + // Weak kings have the same problem, less severely. + if (hi == 13 && lo <= 8) adj -= if (suited) 0.010 else 0.022 + + return adj + } + + private fun build(): DoubleArray { + val random = Random(9_1_2026) + val table = DoubleArray(TABLE_SIZE) { -1.0 } + val entries = ArrayList>(169) + + for (hi in 12 downTo 0) { + for (lo in hi downTo 0) { + if (hi == lo) { + val hole = intArrayOf( + Card.of(hi + 2, Suit.CLUBS).index, + Card.of(hi + 2, Suit.HEARTS).index, + ) + val e = Equity.estimate(hole, IntArray(0), 1, 2500, random) + val k = key(hole) + val score = e + playability(hi + 2, lo + 2, suited = false, pair = true) + table[k] = score + entries.add(k to score) + } else { + val suitedHole = intArrayOf( + Card.of(hi + 2, Suit.SPADES).index, + Card.of(lo + 2, Suit.SPADES).index, + ) + val offHole = intArrayOf( + Card.of(hi + 2, Suit.SPADES).index, + Card.of(lo + 2, Suit.HEARTS).index, + ) + for ((hole, suited) in listOf(suitedHole to true, offHole to false)) { + val e = Equity.estimate(hole, IntArray(0), 1, 2500, random) + val k = key(hole) + val score = e + playability(hi + 2, lo + 2, suited, pair = false) + table[k] = score + entries.add(k to score) + } + } + } + } + + // Convert raw equity into a percentile ranking. + entries.sortByDescending { it.second } + val out = DoubleArray(TABLE_SIZE) { 1.0 } + for ((rank, entry) in entries.withIndex()) { + out[entry.first] = rank.toDouble() / (entries.size - 1) + } + return out + } +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt new file mode 100644 index 0000000..6f59a27 --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -0,0 +1,455 @@ +package com.jsjdesigns.poker.game + +import com.jsjdesigns.poker.core.Card +import com.jsjdesigns.poker.core.CardSource +import com.jsjdesigns.poker.core.Deck +import com.jsjdesigns.poker.core.HandEvaluator +import kotlin.random.Random + +enum class Street { PREFLOP, FLOP, TURN, RIVER } + +enum class ActionType { FOLD, CHECK, CALL, BET, RAISE } + +/** For BET and RAISE, [amount] is the total this player is committing *to* this round. */ +data class Action(val type: ActionType, val amount: Int = 0) { + override fun toString(): String = when (type) { + ActionType.FOLD -> "folds" + ActionType.CHECK -> "checks" + ActionType.CALL -> "calls $amount" + ActionType.BET -> "bets $amount" + ActionType.RAISE -> "raises to $amount" + } +} + +data class HandEvent(val street: Street, val seat: Int, val name: String, val action: Action) + +class Seat( + val index: Int, + val name: String, + var stack: Int, + val agent: PlayerAgent, +) { + var hole: IntArray = IntArray(0) + var committedThisRound = 0 + var committedThisHand = 0 + var folded = false + var allIn = false + var hasActed = false + + val canAct: Boolean get() = !folded && !allIn && stack > 0 + val contesting: Boolean get() = !folded +} + +/** Everything a player may legally know when it is their turn. */ +class DecisionContext( + val street: Street, + val seat: Seat, + val board: IntArray, + val pot: Int, + val toCall: Int, + val minRaiseTo: Int, + val maxRaiseTo: Int, + val activeOpponents: Int, + /** How many players act after this one on this street; 0 means last to act. */ + val seatsActingAfter: Int, + val bigBlind: Int, + val history: List, +) { + val hole: IntArray get() = seat.hole + val stack: Int get() = seat.stack + val canCheck: Boolean get() = toCall == 0 + + /** + * True when this player may still put in a raise. + * + * A player who has already acted at the current bet level and is only facing + * an *incomplete* raise (a short all-in) owes the difference but may not + * re-raise. A full raise resets [Seat.hasActed], restoring the right. + */ + val canRaise: Boolean get() = seat.stack > toCall && !seat.hasActed + val inPosition: Boolean get() = seatsActingAfter == 0 +} + +fun interface PlayerAgent { + fun act(ctx: DecisionContext): Action +} + +data class Pot(val amount: Int, val eligible: List) + +data class HandResult( + val board: IntArray, + /** Net chip change per seat for this hand. */ + val net: IntArray, + val winners: List, + val wentToShowdown: Boolean, + val potSize: Int, + val events: List, +) + +/** + * A no-limit Texas Hold'em table. + * + * Deliberately headless and synchronous: the same engine runs the on-device game + * and the batch simulator used to tune bot profiles. + */ +class Table( + val seats: List, + val smallBlind: Int, + val bigBlind: Int, + private val random: Random = Random.Default, + private val deck: CardSource = Deck(random), +) { + var button: Int = 0 + private set + + val board = ArrayList(5) + private val events = ArrayList() + + private var currentBet = 0 + private var minRaiseSize = 0 + + fun advanceButton() { + button = nextOccupied(button) + } + + private fun nextOccupied(from: Int): Int { + var i = (from + 1) % seats.size + var guard = 0 + while (seats[i].stack <= 0 && guard++ < seats.size) i = (i + 1) % seats.size + return i + } + + private fun nextInHand(from: Int): Int { + var i = (from + 1) % seats.size + var guard = 0 + while (!seats[i].contesting && guard++ < seats.size) i = (i + 1) % seats.size + return i + } + + fun playHand(): HandResult { + val startingStacks = IntArray(seats.size) { seats[it].stack } + resetForHand() + + val live = seats.filter { it.stack > 0 } + require(live.size >= 2) { "need at least two funded players" } + + postBlinds() + dealHoleCards() + + var street = Street.PREFLOP + var finished = false + + while (!finished) { + val first = firstToAct(street) + runBettingRound(street, first) + + val stillIn = seats.count { it.contesting } + if (stillIn <= 1) { + finished = true + break + } + + // If everyone left is all-in, run the remaining board out unopposed. + val canStillBet = seats.count { it.contesting && !it.allIn } + if (canStillBet <= 1 && street != Street.RIVER) { + dealRemainingBoard(street) + street = Street.RIVER + finished = true + break + } + + street = when (street) { + Street.PREFLOP -> { dealFlop(); Street.FLOP } + Street.FLOP -> { dealTurn(); Street.TURN } + Street.TURN -> { dealRiver(); Street.RIVER } + Street.RIVER -> { finished = true; Street.RIVER } + } + } + + return settle(startingStacks) + } + + private fun resetForHand() { + deck.shuffle() + board.clear() + events.clear() + currentBet = 0 + minRaiseSize = bigBlind + for (s in seats) { + s.hole = IntArray(0) + s.committedThisRound = 0 + s.committedThisHand = 0 + s.folded = s.stack <= 0 + s.allIn = false + s.hasActed = false + } + } + + private fun postBlinds() { + val funded = seats.filter { it.stack > 0 } + val headsUp = funded.size == 2 + // Heads-up: the button posts the small blind and acts first pre-flop. + val sbSeat = if (headsUp) button else nextOccupied(button) + val bbSeat = nextOccupied(sbSeat) + + commit(seats[sbSeat], smallBlind) + commit(seats[bbSeat], bigBlind) + currentBet = bigBlind + minRaiseSize = bigBlind + } + + private fun dealHoleCards() { + for (s in seats) if (s.stack > 0 || s.committedThisHand > 0) { + if (!s.folded) s.hole = deck.deal(2) + } + } + + private fun dealFlop() { + deck.deal() // burn + repeat(3) { board.add(deck.deal()) } + } + + private fun dealTurn() { + deck.deal() + board.add(deck.deal()) + } + + private fun dealRiver() { + deck.deal() + board.add(deck.deal()) + } + + private fun dealRemainingBoard(from: Street) { + var s = from + while (s != Street.RIVER) { + s = when (s) { + Street.PREFLOP -> { dealFlop(); Street.FLOP } + Street.FLOP -> { dealTurn(); Street.TURN } + Street.TURN -> { dealRiver(); Street.RIVER } + Street.RIVER -> Street.RIVER + } + } + } + + private fun firstToAct(street: Street): Int { + val funded = seats.count { it.stack > 0 || it.committedThisHand > 0 } + val headsUp = funded == 2 + return if (street == Street.PREFLOP) { + if (headsUp) { + button // heads-up SB/button acts first pre-flop + } else { + val sb = nextOccupied(button) + val bb = nextOccupied(sb) + nextInHand(bb) + } + } else { + if (headsUp) nextInHand(button) else nextInHand(button) + } + } + + private fun commit(seat: Seat, amount: Int): Int { + val actual = amount.coerceAtMost(seat.stack) + seat.stack -= actual + seat.committedThisRound += actual + seat.committedThisHand += actual + if (seat.stack == 0) seat.allIn = true + return actual + } + + private fun runBettingRound(street: Street, firstSeat: Int) { + for (s in seats) { + s.committedThisRound = 0 + s.hasActed = false + } + + if (street == Street.PREFLOP) { + // Blinds were already committed; re-apply them to this round's totals. + val funded = seats.filter { it.stack > 0 || it.committedThisHand > 0 } + val headsUp = funded.size == 2 + val sbSeat = if (headsUp) button else nextOccupied(button) + val bbSeat = nextOccupied(sbSeat) + seats[sbSeat].committedThisRound = minOf(smallBlind, seats[sbSeat].committedThisHand) + seats[bbSeat].committedThisRound = minOf(bigBlind, seats[bbSeat].committedThisHand) + currentBet = bigBlind + } else { + currentBet = 0 + } + minRaiseSize = bigBlind + + if (seats.count { it.canAct } == 0) return + + var i = firstSeat + var guard = 0 + val maxIterations = seats.size * 40 + + while (guard++ < maxIterations) { + if (roundComplete()) break + + val seat = seats[i] + if (seat.canAct && (!seat.hasActed || seat.committedThisRound < currentBet)) { + val toCall = (currentBet - seat.committedThisRound).coerceAtLeast(0) + val ctx = buildContext(street, seat, toCall) + val action = sanitise(seat, toCall, seat.agent.act(ctx)) + apply(street, seat, action, toCall) + seat.hasActed = true + + if (seats.count { it.contesting } <= 1) return + } + i = (i + 1) % seats.size + } + } + + private fun roundComplete(): Boolean { + val actors = seats.filter { it.canAct } + if (actors.isEmpty()) return true + return actors.all { it.hasActed && it.committedThisRound == currentBet } + } + + private fun buildContext(street: Street, seat: Seat, toCall: Int): DecisionContext { + val minRaiseTo = currentBet + minRaiseSize + val maxRaiseTo = seat.committedThisRound + seat.stack + + var after = 0 + var i = (seat.index + 1) % seats.size + while (i != seat.index) { + val o = seats[i] + if (o.canAct && (!o.hasActed || o.committedThisRound < currentBet)) after++ + i = (i + 1) % seats.size + } + + return DecisionContext( + street = street, + seat = seat, + board = board.toIntArray(), + pot = pot(), + toCall = toCall, + minRaiseTo = minRaiseTo, + maxRaiseTo = maxRaiseTo, + activeOpponents = seats.count { it.contesting && it !== seat }, + seatsActingAfter = after, + bigBlind = bigBlind, + history = events, + ) + } + + /** Clamps whatever an agent returns into something legal. */ + private fun sanitise(seat: Seat, toCall: Int, action: Action): Action { + return when (action.type) { + ActionType.FOLD -> if (toCall == 0) Action(ActionType.CHECK) else action + ActionType.CHECK -> if (toCall > 0) Action(ActionType.FOLD) else action + ActionType.CALL -> if (toCall == 0) Action(ActionType.CHECK) else action + ActionType.BET, ActionType.RAISE -> { + // Facing only an incomplete raise after already acting: call or fold. + if (seat.hasActed && toCall > 0) return Action(ActionType.CALL, toCall) + val maxTo = seat.committedThisRound + seat.stack + val minTo = (currentBet + minRaiseSize).coerceAtMost(maxTo) + val target = action.amount.coerceIn(minTo, maxTo) + if (target <= currentBet) { + if (toCall == 0) Action(ActionType.CHECK) else Action(ActionType.CALL, toCall) + } else { + Action(if (currentBet == 0) ActionType.BET else ActionType.RAISE, target) + } + } + } + } + + private fun apply(street: Street, seat: Seat, action: Action, toCall: Int) { + when (action.type) { + ActionType.FOLD -> seat.folded = true + ActionType.CHECK -> Unit + ActionType.CALL -> commit(seat, toCall) + ActionType.BET, ActionType.RAISE -> { + val raiseSize = action.amount - currentBet + commit(seat, action.amount - seat.committedThisRound) + // A short all-in that does not complete a full raise must not reopen betting. + if (raiseSize >= minRaiseSize) { + minRaiseSize = raiseSize + for (other in seats) if (other !== seat && other.canAct) other.hasActed = false + } + currentBet = maxOf(currentBet, seat.committedThisRound) + } + } + events.add(HandEvent(street, seat.index, seat.name, action)) + } + + private fun pot(): Int = seats.sumOf { it.committedThisHand } + + /** + * Refunds any uncalled excess, builds side pots, and awards them. + * + * Side pots are layered at each distinct all-in level: every player contributes + * up to that level, and only players who reached it can win that layer. + */ + private fun settle(startingStacks: IntArray): HandResult { + // Return the portion of a bet nobody could match. + for (s in seats) { + val maxOther = seats.filter { it !== s }.maxOfOrNull { it.committedThisHand } ?: 0 + if (s.committedThisHand > maxOther) { + val refund = s.committedThisHand - maxOther + s.stack += refund + s.committedThisHand -= refund + } + } + + val contenders = seats.filter { it.contesting } + val winners = ArrayList() + var wentToShowdown = false + val potTotal = pot() + + if (contenders.size == 1) { + val w = contenders.first() + w.stack += potTotal + for (s in seats) s.committedThisHand = 0 + winners.add(w.index) + } else { + wentToShowdown = true + val scores = HashMap() + for (c in contenders) { + val seven = IntArray(7) + seven[0] = c.hole[0]; seven[1] = c.hole[1] + for (k in board.indices) seven[2 + k] = board[k] + scores[c.index] = HandEvaluator.evaluate(seven, 2 + board.size) + } + + val levels = contenders.map { it.committedThisHand }.distinct().sorted() + var previous = 0 + val pots = ArrayList() + for (level in levels) { + var amount = 0 + for (s in seats) { + amount += (s.committedThisHand.coerceAtMost(level) - s.committedThisHand.coerceAtMost(previous)) + } + if (amount > 0) { + val eligible = contenders.filter { it.committedThisHand >= level }.map { it.index } + pots.add(Pot(amount, eligible)) + } + previous = level + } + + for (p in pots) { + val best = p.eligible.maxOf { scores.getValue(it) } + val potWinners = p.eligible.filter { scores.getValue(it) == best } + val share = p.amount / potWinners.size + var remainder = p.amount - share * potWinners.size + for (w in potWinners) { + seats[w].stack += share + if (remainder > 0) { seats[w].stack += 1; remainder-- } + if (w !in winners) winners.add(w) + } + } + for (s in seats) s.committedThisHand = 0 + } + + val net = IntArray(seats.size) { seats[it].stack - startingStacks[it] } + return HandResult( + board = board.toIntArray(), + net = net, + winners = winners, + wentToShowdown = wentToShowdown, + potSize = potTotal, + events = ArrayList(events), + ) + } + + fun boardString(): String = board.joinToString(" ") { Card(it).toString() } +} diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/HandEvaluatorTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/HandEvaluatorTest.kt new file mode 100644 index 0000000..66c8166 --- /dev/null +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/HandEvaluatorTest.kt @@ -0,0 +1,143 @@ +package com.jsjdesigns.poker.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class HandEvaluatorTest { + + private fun eval(text: String) = HandEvaluator.evaluate(cardsOf(text)) + private fun cat(text: String) = HandEvaluator.categoryOf(eval(text)) + + @Test + fun `categories are detected`() { + assertEquals(HandEvaluator.STRAIGHT_FLUSH, cat("9h 8h 7h 6h 5h")) + assertEquals(HandEvaluator.QUADS, cat("9h 9s 9d 9c 5h")) + assertEquals(HandEvaluator.FULL_HOUSE, cat("9h 9s 9d 5c 5h")) + assertEquals(HandEvaluator.FLUSH, cat("Ah Jh 7h 4h 2h")) + assertEquals(HandEvaluator.STRAIGHT, cat("9h 8s 7d 6c 5h")) + assertEquals(HandEvaluator.TRIPS, cat("9h 9s 9d 6c 5h")) + assertEquals(HandEvaluator.TWO_PAIR, cat("9h 9s 6d 6c 5h")) + assertEquals(HandEvaluator.PAIR, cat("9h 9s 8d 6c 5h")) + assertEquals(HandEvaluator.HIGH_CARD, cat("Ah Js 8d 6c 5h")) + } + + @Test + fun `wheel counts as a five high straight`() { + assertEquals(HandEvaluator.STRAIGHT, cat("Ah 2s 3d 4c 5h")) + // ...and must lose to a six-high straight + assertTrue(eval("Ah 2s 3d 4c 5h") < eval("2s 3d 4c 5h 6d")) + } + + @Test + fun `steel wheel is a straight flush`() { + assertEquals(HandEvaluator.STRAIGHT_FLUSH, cat("Ah 2h 3h 4h 5h")) + assertTrue(eval("Ah 2h 3h 4h 5h") < eval("6h 2h 3h 4h 5h")) + } + + @Test + fun `ace high straight beats king high straight`() { + assertTrue(eval("Ah Ks Qd Jc Th") > eval("Ks Qd Jc Th 9h")) + } + + @Test + fun `category ordering holds`() { + val ascending = listOf( + "Ah Js 8d 6c 5h", // high card + "9h 9s 8d 6c 5h", // pair + "9h 9s 6d 6c 5h", // two pair + "9h 9s 9d 6c 5h", // trips + "9h 8s 7d 6c 5h", // straight + "Ah Jh 7h 4h 2h", // flush + "9h 9s 9d 5c 5h", // full house + "9h 9s 9d 9c 5h", // quads + "9h 8h 7h 6h 5h", // straight flush + ).map { eval(it) } + + for (i in 1 until ascending.size) { + assertTrue(ascending[i] > ascending[i - 1], "rank $i should beat rank ${i - 1}") + } + } + + @Test + fun `kickers break ties`() { + assertTrue(eval("9h 9s Ad 6c 5h") > eval("9h 9s Kd 6c 5h")) + assertTrue(eval("9h 9s 6d 6c Ah") > eval("9h 9s 6d 6c Kh")) + assertTrue(eval("Ah As Ad Ac Kh") > eval("Ah As Ad Ac Qh")) + assertEquals(eval("9h 9s 6d 6c Ah"), eval("9d 9c 6s 6h As")) + } + + @Test + fun `two trips make a full house using the higher set`() { + // 7s full of 5s, not 5s full of 7s + val score = eval("7h 7s 7d 5c 5h 5s 2d") + assertEquals(HandEvaluator.FULL_HOUSE, HandEvaluator.categoryOf(score)) + assertEquals(score, eval("7h 7s 7d 5c 5h")) + } + + @Test + fun `seven cards pick the best five`() { + // Flush is available and must be chosen over the pair + assertEquals(HandEvaluator.FLUSH, HandEvaluator.categoryOf(eval("Ah Jh 7h 4h 2h 9s 9d"))) + // Straight flush available among seven + assertEquals(HandEvaluator.STRAIGHT_FLUSH, HandEvaluator.categoryOf(eval("9h 8h 7h 6h 5h As Kd"))) + // Six cards to a flush -> best five of that suit + assertEquals(eval("Ah Kh Qh Jh 9h"), eval("Ah Kh Qh Jh 9h 2h 3s")) + } + + @Test + fun `board plays when hole cards do not improve it`() { + val board = "Ah Kh Qh Jh Th" + assertEquals(eval("$board 2c 3d"), eval("$board 2s 3h")) + } + + /** + * Exhaustive check against the known frequencies of five-card poker hands. + * If any branch of the evaluator is wrong these counts move, so this is the + * test that actually proves correctness rather than spot-checking it. + */ + @Test + fun `all 2598960 five card hands match published frequencies`() { + val expected = intArrayOf( + 1302540, // high card + 1098240, // pair + 123552, // two pair + 54912, // trips + 10200, // straight + 5108, // flush + 3744, // full house + 624, // quads + 40, // straight flush + ) + + val counts = IntArray(9) + val hand = IntArray(5) + var total = 0 + + for (a in 0 until 48) { + hand[0] = a + for (b in a + 1 until 49) { + hand[1] = b + for (c in b + 1 until 50) { + hand[2] = c + for (d in c + 1 until 51) { + hand[3] = d + for (e in d + 1 until 52) { + hand[4] = e + counts[HandEvaluator.categoryOf(HandEvaluator.evaluate(hand))]++ + total++ + } + } + } + } + } + + assertEquals(2_598_960, total) + for (i in 0..8) { + assertEquals( + expected[i], counts[i], + "${HandEvaluator.CATEGORY_NAMES[i]} count mismatch", + ) + } + } +} diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/PreflopChartTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/PreflopChartTest.kt new file mode 100644 index 0000000..956e067 --- /dev/null +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/PreflopChartTest.kt @@ -0,0 +1,91 @@ +package com.jsjdesigns.poker.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PreflopChartTest { + + private fun p(text: String) = PreflopChart.percentile(cardsOf(text)) + + /** Lower percentile == stronger hand. */ + private fun assertStronger(better: String, worse: String) { + assertTrue( + p(better) < p(worse), + "$better (${"%.3f".format(p(better))}) should rank above $worse (${"%.3f".format(p(worse))})", + ) + } + + @Test + fun `aces are the best starting hand`() { + assertEquals(0.0, p("Ah Ad")) + } + + @Test + fun `big pairs are correctly ordered`() { + assertStronger("Ah Ad", "Kh Kd") + assertStronger("Kh Kd", "Qh Qd") + assertStronger("Qh Qd", "Jh Jd") + } + + @Test + fun `suitedness is worth something`() { + assertStronger("Ah Kh", "Ah Kd") + assertStronger("7h 6h", "7h 6d") + assertStronger("Jh Th", "Jh Td") + } + + @Test + fun `connectedness beats equivalent gappers`() { + assertStronger("7h 6h", "7h 2h") + assertStronger("9h 8h", "9h 4h") + } + + @Test + fun `weak aces are discounted for domination`() { + // A2o has strong all-in equity but is a reverse-implied-odds trap. + assertStronger("Ah Kd", "Ah 2d") + assertStronger("Ah Qd", "Ah 3d") + } + + @Test + fun `suited connectors outrank junk with similar raw equity`() { + assertStronger("7h 6h", "7h 2d") + assertStronger("6h 5h", "Kd 3c") + } + + @Test + fun `worst hands really are worst`() { + assertTrue(p("7h 2d") > 0.90, "72o should sit in the bottom 10%, was ${p("7h 2d")}") + assertTrue(p("8h 3d") > 0.85, "83o should be near the bottom, was ${p("8h 3d")}") + } + + @Test + fun `card order does not matter`() { + assertEquals(p("Ah Kd"), p("Kd Ah")) + assertEquals(p("7h 6h"), p("6h 7h")) + } + + @Test + fun `all percentiles are within range and reasonably distributed`() { + val samples = listOf("Ah Ad", "Kh Qh", "9h 8d", "7h 2d", "Th Td", "Ah 5h") + for (s in samples) { + val v = p(s) + assertTrue(v in 0.0..1.0, "$s percentile out of range: $v") + } + // A premium hand and a trash hand must not land close together. + assertTrue(p("7h 2d") - p("Ah Ad") > 0.8, "range is too compressed to gate on") + } + + @Test + fun `a tight range admits few hands and a loose range admits many`() { + val all = ArrayList() + for (a in 0 until 51) for (b in a + 1 until 52) { + all.add(PreflopChart.percentile(intArrayOf(a, b))) + } + val tight = all.count { it <= 0.12 } + val loose = all.count { it <= 0.60 } + assertTrue(tight < loose, "a 12% range must be narrower than a 60% range") + assertTrue(tight > 0, "a 12% range must admit something") + } +} diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt new file mode 100644 index 0000000..a36c43d --- /dev/null +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt @@ -0,0 +1,247 @@ +package com.jsjdesigns.poker.game + +import com.jsjdesigns.poker.core.StackedDeck +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** A snapshot of what a player was offered, taken before the seat mutates. */ +private data class Offer( + val street: Street, + val toCall: Int, + val canRaise: Boolean, + val canCheck: Boolean, + val pot: Int, + val minRaiseTo: Int, + val maxRaiseTo: Int, +) + +private class Scripted(private vararg val actions: Action) : PlayerAgent { + private var i = 0 + val offers = mutableListOf() + + override fun act(ctx: DecisionContext): Action { + offers += Offer( + ctx.street, ctx.toCall, ctx.canRaise, ctx.canCheck, + ctx.pot, ctx.minRaiseTo, ctx.maxRaiseTo, + ) + return actions.getOrElse(i++) { + if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD) + } + } +} + +private class RandomAgent(private val random: Random) : PlayerAgent { + override fun act(ctx: DecisionContext): Action = when (random.nextInt(5)) { + 0 -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD) + 1, 2 -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall) + else -> Action(ActionType.RAISE, ctx.minRaiseTo + random.nextInt(50)) + } +} + +class TableRulesTest { + + // ---------- incomplete (short all-in) raises ---------- + + @Test + fun `short all-in does not reopen betting for a player who already acted`() { + val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 30)) + val p1 = Scripted(Action(ActionType.RAISE, 130)) // all-in, only a 30 raise + val p2 = Scripted(Action(ActionType.FOLD)) + + val seats = listOf( + Seat(0, "P0", 1000, p0), + Seat(1, "P1", 130, p1), + Seat(2, "P2", 1000, p2), + ) + Table(seats, 10, 20, Random(1), StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand() + + // P0 acts twice: opens, then faces the incomplete all-in. + assertEquals(2, p0.offers.size, "P0 should be asked to act twice") + assertTrue(p0.offers[0].canRaise, "P0 may raise when first to act") + assertEquals(30, p0.offers[1].toCall, "P0 owes the extra 30") + assertFalse( + p0.offers[1].canRaise, + "an incomplete all-in must not reopen betting for a player who already acted", + ) + } + + @Test + fun `a full raise does reopen betting`() { + val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.FOLD)) + val p1 = Scripted(Action(ActionType.RAISE, 300)) // full re-raise + val p2 = Scripted(Action(ActionType.FOLD)) + + val seats = listOf( + Seat(0, "P0", 1000, p0), + Seat(1, "P1", 1000, p1), + Seat(2, "P2", 1000, p2), + ) + Table(seats, 10, 20, Random(1), StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand() + + assertEquals(2, p0.offers.size) + assertTrue(p0.offers[1].canRaise, "a full raise restores the right to re-raise") + } + + @Test + fun `an illegal raise attempt is downgraded to a call`() { + // P0 tries to re-raise after only an incomplete all-in; engine must clamp it. + val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.RAISE, 500)) + val p1 = Scripted(Action(ActionType.RAISE, 130)) + val p2 = Scripted(Action(ActionType.FOLD)) + + val seats = listOf( + Seat(0, "P0", 1000, p0), + Seat(1, "P1", 130, p1), + Seat(2, "P2", 1000, p2), + ) + val result = Table(seats, 10, 20, Random(1), + StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand() + + val p0Raises = result.events.count { it.seat == 0 && it.action.type == ActionType.RAISE } + assertEquals(1, p0Raises, "the illegal second raise must be downgraded, not accepted") + } + + // ---------- side pots ---------- + + @Test + fun `side pots pay the short stack from the main pot only`() { + // P0 all-in for 50 with aces, P1 and P2 fight for the rest. + val p0 = Scripted(Action(ActionType.RAISE, 50)) + val p1 = Scripted(Action(ActionType.CALL, 40), Action(ActionType.RAISE, 150), Action(ActionType.CHECK)) + val p2 = Scripted(Action(ActionType.CALL, 30), Action(ActionType.CALL, 150), Action(ActionType.CHECK)) + + val seats = listOf( + Seat(0, "P0", 50, p0), + Seat(1, "P1", 200, p1), + Seat(2, "P2", 200, p2), + ) + val result = Table( + seats, 10, 20, Random(1), + StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"), + ).playHand() + + assertEquals(0, result.net.sum(), "chips must be conserved") + // Aces take the 150 main pot -> +100 net on a 50 stack. + assertEquals(100, result.net[0], "short stack wins main pot only") + // Kings beat queens for the side pot. + assertTrue(result.net[1] > 0, "kings should win the side pot") + assertTrue(result.net[2] < 0, "queens should lose") + } + + @Test + fun `split pots conserve odd chips`() { + // P0 and P1 play the same board; the pot must split without losing a chip. + val p0 = Scripted(Action(ActionType.CALL, 10), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK)) + val p1 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK)) + + val seats = listOf(Seat(0, "P0", 501, p0), Seat(1, "P1", 501, p1)) + val result = Table( + seats, 5, 10, Random(1), + // Board plays: both hold rags, the royal flush on board is the hand. + StackedDeck.of(listOf("2c 3d", "2h 3s"), "Ah Kh Qh Jh Th"), + ).playHand() + + assertEquals(0, result.net.sum(), "odd chips must not vanish") + assertEquals(2, result.winners.size, "board plays -> split pot") + } + + // ---------- uncalled bets ---------- + + @Test + fun `an uncalled bet is returned`() { + val p0 = Scripted(Action(ActionType.RAISE, 400)) + val p1 = Scripted(Action(ActionType.FOLD)) + val p2 = Scripted(Action(ActionType.FOLD)) + + val seats = listOf( + Seat(0, "P0", 1000, p0), + Seat(1, "P1", 1000, p1), + Seat(2, "P2", 1000, p2), + ) + val result = Table(seats, 10, 20, Random(1), + StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand() + + assertEquals(0, result.net.sum()) + // P0 wins only the blinds; the uncalled 400 comes back. + assertEquals(30, result.net[0], "winner collects the blinds, not their own uncalled bet") + } + + // ---------- blinds and action order ---------- + + @Test + fun `heads up button posts the small blind and acts first preflop`() { + val p0 = Scripted(Action(ActionType.CALL, 5), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK)) + val p1 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK)) + + val seats = listOf(Seat(0, "P0", 500, p0), Seat(1, "P1", 500, p1)) + val result = Table(seats, 5, 10, Random(1), + StackedDeck.of(listOf("2c 3d", "2h 3s"), "Ah Kh Qh Jh Th")).playHand() + + val firstPreflop = result.events.first { it.street == Street.PREFLOP } + assertEquals(0, firstPreflop.seat, "heads-up, the button acts first pre-flop") + // Button posted the small blind, so it owes 5 to complete. + assertEquals(5, p0.offers.first().toCall) + } + + @Test + fun `six handed action starts left of the big blind`() { + val agents = List(6) { Scripted() } + val seats = agents.mapIndexed { i, a -> Seat(i, "P$i", 500, a) } + val result = Table(seats, 5, 10, Random(1), StackedDeck.of(List(6) { "" }.let { + listOf("Ah Ad", "Kh Kd", "Qh Qd", "Jh Jd", "Th Td", "9h 9d") + }, "2c 7d 4s 5c 3h")).playHand() + + // Button defaults to seat 0 -> SB seat 1, BB seat 2, first to act seat 3. + val firstPreflop = result.events.first { it.street == Street.PREFLOP } + assertEquals(3, firstPreflop.seat, "under the gun is left of the big blind") + } + + // ---------- malformed agent output ---------- + + @Test + fun `malformed actions are sanitised`() { + // Tries to check facing a bet, and to bet far beyond its stack. + val p0 = Scripted(Action(ActionType.RAISE, 999_999)) + val p1 = Scripted(Action(ActionType.CHECK)) + val p2 = Scripted(Action(ActionType.FOLD)) + + val seats = listOf( + Seat(0, "P0", 300, p0), + Seat(1, "P1", 300, p1), + Seat(2, "P2", 300, p2), + ) + val result = Table(seats, 10, 20, Random(1), + StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand() + + assertEquals(0, result.net.sum()) + assertTrue(seats.all { it.stack >= 0 }, "no stack may go negative") + // The oversized raise must have been clamped to the 300 stack. + val raise = result.events.first { it.seat == 0 && it.action.type == ActionType.RAISE } + assertTrue(raise.action.amount <= 300, "raise clamped to stack, was ${raise.action.amount}") + // Checking into a bet becomes a fold. + assertEquals(ActionType.FOLD, result.events.first { it.seat == 1 }.action.type) + } + + // ---------- invariants under fuzzing ---------- + + @Test + fun `chips are conserved and stacks stay non-negative over many random hands`() { + val random = Random(4242) + val seats = List(6) { Seat(it, "P$it", 400, RandomAgent(random)) } + val table = Table(seats, 5, 10, random) + + val startingTotal = seats.sumOf { it.stack } + repeat(3000) { + table.advanceButton() + val result = table.playHand() + assertEquals(0, result.net.sum(), "hand did not conserve chips") + assertTrue(seats.all { it.stack >= 0 }, "a stack went negative") + // Re-seed short stacks so play continues. + for (s in seats) if (s.stack < 20) s.stack = 400 + } + assertTrue(startingTotal > 0) + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..d76d76d --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m +org.gradle.parallel=true +org.gradle.caching=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8e61ef1 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cf4fe76 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Poker" +include(":engine", ":sim") diff --git a/sim/build.gradle.kts b/sim/build.gradle.kts new file mode 100644 index 0000000..5929248 --- /dev/null +++ b/sim/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + kotlin("jvm") + application +} + +dependencies { + implementation(project(":engine")) +} + +application { + mainClass.set("com.jsjdesigns.poker.sim.MainKt") +} diff --git a/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt b/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt new file mode 100644 index 0000000..94f0a55 --- /dev/null +++ b/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt @@ -0,0 +1,176 @@ +package com.jsjdesigns.poker.sim + +import com.jsjdesigns.poker.bot.BotProfile +import com.jsjdesigns.poker.bot.MathBot +import com.jsjdesigns.poker.bot.PlayStyle +import com.jsjdesigns.poker.bot.SkillLevel +import com.jsjdesigns.poker.core.Card +import com.jsjdesigns.poker.core.PreflopChart +import com.jsjdesigns.poker.core.Suit +import com.jsjdesigns.poker.game.ActionType +import com.jsjdesigns.poker.game.Seat +import com.jsjdesigns.poker.game.Street +import com.jsjdesigns.poker.game.Table +import kotlin.math.abs +import kotlin.random.Random + +private const val SMALL_BLIND = 1 +private const val BIG_BLIND = 2 +private const val STARTING_STACK = 200 // 100 big blinds + +private class Stats(val name: String, val profile: BotProfile) { + var net = 0L + var hands = 0 + var vpip = 0 + var pfr = 0 + var wins = 0 + var postflopBets = 0 + var postflopCalls = 0 + + fun bbPer100(): Double = if (hands == 0) 0.0 else (net.toDouble() / BIG_BLIND) / hands * 100.0 + fun pct(n: Int): Double = if (hands == 0) 0.0 else n * 100.0 / hands + fun aggressionFactor(): Double = + if (postflopCalls == 0) postflopBets.toDouble() else postflopBets.toDouble() / postflopCalls +} + +private fun runTable(label: String, roster: List, hands: Int, seed: Long): List { + // The deck gets its own RNG. If bots drew from the same stream, the number of + // Monte Carlo rollouts a bot performs — which varies by skill level — would + // shift every subsequent deal, so changing a profile would silently change the + // cards and no two runs would be comparable. + val deckRandom = Random(seed) + val bots = roster.mapIndexed { i, p -> MathBot(p, Random(seed * 31 + i)) } + val stats = roster.map { Stats(it.name, it) } + val seats = roster.mapIndexed { i, p -> Seat(i, p.name, STARTING_STACK, bots[i]) } + val table = Table(seats, SMALL_BLIND, BIG_BLIND, deckRandom) + + println("\n=== $label ===") + println("$hands hands, ${roster.size}-handed, ${STARTING_STACK / BIG_BLIND}bb stacks, seed=$seed") + val started = System.nanoTime() + + // VPIP/PFR are per-hand booleans, not action counts: a player who calls and + // then calls a re-raise has still only entered the pot once. + val enteredPot = BooleanArray(seats.size) + val raisedPre = BooleanArray(seats.size) + + repeat(hands) { + for (s in seats) s.stack = STARTING_STACK + java.util.Arrays.fill(enteredPot, false) + java.util.Arrays.fill(raisedPre, false) + table.advanceButton() + + val result = table.playHand() + + for (e in result.events) { + val st = stats[e.seat] + if (e.street == Street.PREFLOP) { + when (e.action.type) { + ActionType.CALL -> enteredPot[e.seat] = true + ActionType.BET, ActionType.RAISE -> { + enteredPot[e.seat] = true; raisedPre[e.seat] = true + } + else -> Unit + } + } else { + when (e.action.type) { + ActionType.BET, ActionType.RAISE -> st.postflopBets++ + ActionType.CALL -> st.postflopCalls++ + else -> Unit + } + } + } + + for (i in seats.indices) { + val st = stats[i] + st.hands++ + st.net += result.net[i] + if (enteredPot[i]) st.vpip++ + if (raisedPre[i]) st.pfr++ + if (i in result.winners) st.wins++ + + val bb = abs(result.net[i]).toDouble() / BIG_BLIND + if (result.net[i] > 0) bots[i].mood.recordWin(bb) + else if (result.net[i] < 0) bots[i].mood.recordLoss(bb, wasBadBeat = result.wentToShowdown && bb > 25) + bots[i].mood.decay() + } + } + + val elapsed = (System.nanoTime() - started) / 1_000_000.0 + println("%.1f ms (%.0f hands/sec)\n".format(elapsed, hands / (elapsed / 1000.0))) + + println("%-7s %-28s %9s %7s %7s %6s %7s".format("Player", "Profile", "bb/100", "VPIP%", "PFR%", "AF", "Won%")) + println("-".repeat(78)) + for (s in stats.sortedByDescending { it.bbPer100() }) { + println( + "%-7s %-28s %9.2f %7.1f %7.1f %6.2f %7.1f".format( + s.name, s.profile.description, s.bbPer100(), + s.pct(s.vpip), s.pct(s.pfr), s.aggressionFactor(), s.pct(s.wins), + ) + ) + } + println("chip conservation: %d (must be 0)".format(stats.sumOf { it.net })) + return stats +} + +/** Dumps the starting-hand ranking so the ordering can be eyeballed against a real chart. */ +private fun printChart() { + val rows = ArrayList>() + for (hi in 14 downTo 2) for (lo in hi downTo 2) { + if (hi == lo) { + val h = intArrayOf(Card.of(hi, Suit.CLUBS).index, Card.of(hi, Suit.HEARTS).index) + rows += "${Card.rankSymbol(hi)}${Card.rankSymbol(lo)} " to PreflopChart.percentile(h) + } else { + val s = intArrayOf(Card.of(hi, Suit.SPADES).index, Card.of(lo, Suit.SPADES).index) + val o = intArrayOf(Card.of(hi, Suit.SPADES).index, Card.of(lo, Suit.HEARTS).index) + rows += "${Card.rankSymbol(hi)}${Card.rankSymbol(lo)}s " to PreflopChart.percentile(s) + rows += "${Card.rankSymbol(hi)}${Card.rankSymbol(lo)}o " to PreflopChart.percentile(o) + } + } + rows.sortBy { it.second } + println("Top 30 starting hands:") + rows.take(30).forEachIndexed { i, (h, v) -> print("%2d.%s%.3f ".format(i + 1, h, v)); if ((i + 1) % 5 == 0) println() } + println("\nBottom 10:") + rows.takeLast(10).forEach { (h, v) -> print("$h%.3f ".format(v)) } + println() +} + +fun main(args: Array) { + if (args.firstOrNull() == "chart") { printChart(); return } + val hands = args.getOrNull(0)?.toIntOrNull() ?: 50_000 + val seed = args.getOrNull(1)?.toLongOrNull() ?: 20_260_724L + + // A mixed table: what a real game looks like. + runTable( + "Mixed table", listOf( + BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."), + BotProfile("Bruno", SkillLevel.ADVANCED, PlayStyle.LOOSE_AGGRESSIVE, "Relentless pressure."), + BotProfile("Cleo", SkillLevel.INTERMEDIATE, PlayStyle.TRAPPER, "Quiet until she has you."), + BotProfile("Dex", SkillLevel.INTERMEDIATE, PlayStyle.CALLING_STATION, "Pays to see it."), + BotProfile("Enzo", SkillLevel.BEGINNER, PlayStyle.MANIAC, "Chaos, and certain he's winning."), + BotProfile("Fay", SkillLevel.BEGINNER, PlayStyle.ROCK, "Waits for aces."), + ), hands, seed + ) + + // Controlled: identical style, only skill varies. This is the experiment that + // actually tests whether the difficulty axis produces a real skill gradient. + val ladder = runTable( + "Skill ladder (style held constant at Tight-Aggressive)", listOf( + BotProfile("Expert", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE), + BotProfile("Advncd", SkillLevel.ADVANCED, PlayStyle.TIGHT_AGGRESSIVE), + BotProfile("Interm", SkillLevel.INTERMEDIATE, PlayStyle.TIGHT_AGGRESSIVE), + BotProfile("Begin", SkillLevel.BEGINNER, PlayStyle.TIGHT_AGGRESSIVE), + ), hands, seed + 1 + ) + + println("\nDifficulty gradient (must decrease monotonically):") + var monotonic = true + var previous = Double.MAX_VALUE + for (level in SkillLevel.entries.reversed()) { // strongest first + val s = ladder.firstOrNull { it.profile.skill == level } ?: continue + val v = s.bbPer100() + println(" %-14s %9.2f bb/100".format(level.label, v)) + if (v > previous) monotonic = false + previous = v + } + println(if (monotonic) " -> PASS: stronger skill earns more." else " -> FAIL: gradient inverted somewhere.") +}