From 479be1f6b9f69121e676c1e669924c2853d9c2d0 Mon Sep 17 00:00:00 2001 From: Jay Date: Sat, 25 Jul 2026 04:36:03 -0400 Subject: [PATCH] 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) --- .gitignore | 29 + CLAUDE.md | 68 + assets/cards/10_of_clubs.svg | 147 + assets/cards/10_of_diamonds.svg | 141 + assets/cards/10_of_hearts.svg | 135 + assets/cards/10_of_spades.svg | 121 + assets/cards/2_of_clubs.svg | 95 + assets/cards/2_of_diamonds.svg | 89 + assets/cards/2_of_hearts.svg | 83 + assets/cards/2_of_spades.svg | 69 + assets/cards/3_of_clubs.svg | 99 + assets/cards/3_of_diamonds.svg | 93 + assets/cards/3_of_hearts.svg | 87 + assets/cards/3_of_spades.svg | 73 + assets/cards/4_of_clubs.svg | 103 + assets/cards/4_of_diamonds.svg | 97 + assets/cards/4_of_hearts.svg | 91 + assets/cards/4_of_spades.svg | 93 + assets/cards/5_of_clubs.svg | 107 + assets/cards/5_of_diamonds.svg | 101 + assets/cards/5_of_hearts.svg | 99 + assets/cards/5_of_spades.svg | 101 + assets/cards/6_of_clubs.svg | 111 + assets/cards/6_of_diamonds.svg | 105 + assets/cards/6_of_hearts.svg | 99 + assets/cards/6_of_spades.svg | 85 + assets/cards/7_of_clubs.svg | 115 + assets/cards/7_of_diamonds.svg | 109 + assets/cards/7_of_hearts.svg | 103 + assets/cards/7_of_spades.svg | 89 + assets/cards/8_of_clubs.svg | 119 + assets/cards/8_of_diamonds.svg | 113 + assets/cards/8_of_hearts.svg | 107 + assets/cards/8_of_spades.svg | 93 + assets/cards/9_of_clubs.svg | 123 + assets/cards/9_of_diamonds.svg | 117 + assets/cards/9_of_hearts.svg | 111 + assets/cards/9_of_spades.svg | 97 + assets/cards/LICENSE.md | 35 + assets/cards/ace_of_clubs.svg | 91 + assets/cards/ace_of_diamonds.svg | 85 + assets/cards/ace_of_hearts.svg | 79 + assets/cards/ace_of_spades.svg | 65 + assets/cards/back.svg | 87 + assets/cards/back_blue.svg | 87 + assets/cards/back_red.svg | 87 + assets/cards/jack_of_clubs.svg | 1686 + assets/cards/jack_of_diamonds.svg | 1253 + assets/cards/jack_of_hearts.svg | 1506 + assets/cards/jack_of_spades.svg | 1692 + assets/cards/king_of_clubs.svg | 1673 + assets/cards/king_of_diamonds.svg | 1306 + assets/cards/king_of_hearts.svg | 2214 ++ assets/cards/king_of_spades.svg | 1245 + assets/cards/playing_cards_deck.svg | 28809 ++++++++++++++++ assets/cards/queen_of_clubs.svg | 1734 + assets/cards/queen_of_diamonds.svg | 1304 + assets/cards/queen_of_hearts.svg | 1741 + assets/cards/queen_of_spades.svg | 1562 + build.gradle.kts | 4 + engine/build.gradle.kts | 15 + .../com/jsjdesigns/poker/bot/MathBot.kt | 257 + .../com/jsjdesigns/poker/bot/OpponentModel.kt | 44 + .../com/jsjdesigns/poker/bot/Profiles.kt | 104 + .../kotlin/com/jsjdesigns/poker/core/Card.kt | 75 + .../com/jsjdesigns/poker/core/CardSource.kt | 55 + .../kotlin/com/jsjdesigns/poker/core/Deck.kt | 31 + .../com/jsjdesigns/poker/core/Equity.kt | 93 + .../jsjdesigns/poker/core/HandEvaluator.kt | 157 + .../com/jsjdesigns/poker/core/PreflopChart.kt | 140 + .../kotlin/com/jsjdesigns/poker/game/Table.kt | 455 + .../poker/core/HandEvaluatorTest.kt | 143 + .../jsjdesigns/poker/core/PreflopChartTest.kt | 91 + .../jsjdesigns/poker/game/TableRulesTest.kt | 247 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 251 + gradlew.bat | 94 + settings.gradle.kts | 24 + sim/build.gradle.kts | 12 + .../kotlin/com/jsjdesigns/poker/sim/Main.kt | 176 + 82 files changed, 54638 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 assets/cards/10_of_clubs.svg create mode 100644 assets/cards/10_of_diamonds.svg create mode 100644 assets/cards/10_of_hearts.svg create mode 100644 assets/cards/10_of_spades.svg create mode 100644 assets/cards/2_of_clubs.svg create mode 100644 assets/cards/2_of_diamonds.svg create mode 100644 assets/cards/2_of_hearts.svg create mode 100644 assets/cards/2_of_spades.svg create mode 100644 assets/cards/3_of_clubs.svg create mode 100644 assets/cards/3_of_diamonds.svg create mode 100644 assets/cards/3_of_hearts.svg create mode 100644 assets/cards/3_of_spades.svg create mode 100644 assets/cards/4_of_clubs.svg create mode 100644 assets/cards/4_of_diamonds.svg create mode 100644 assets/cards/4_of_hearts.svg create mode 100644 assets/cards/4_of_spades.svg create mode 100644 assets/cards/5_of_clubs.svg create mode 100644 assets/cards/5_of_diamonds.svg create mode 100644 assets/cards/5_of_hearts.svg create mode 100644 assets/cards/5_of_spades.svg create mode 100644 assets/cards/6_of_clubs.svg create mode 100644 assets/cards/6_of_diamonds.svg create mode 100644 assets/cards/6_of_hearts.svg create mode 100644 assets/cards/6_of_spades.svg create mode 100644 assets/cards/7_of_clubs.svg create mode 100644 assets/cards/7_of_diamonds.svg create mode 100644 assets/cards/7_of_hearts.svg create mode 100644 assets/cards/7_of_spades.svg create mode 100644 assets/cards/8_of_clubs.svg create mode 100644 assets/cards/8_of_diamonds.svg create mode 100644 assets/cards/8_of_hearts.svg create mode 100644 assets/cards/8_of_spades.svg create mode 100644 assets/cards/9_of_clubs.svg create mode 100644 assets/cards/9_of_diamonds.svg create mode 100644 assets/cards/9_of_hearts.svg create mode 100644 assets/cards/9_of_spades.svg create mode 100644 assets/cards/LICENSE.md create mode 100644 assets/cards/ace_of_clubs.svg create mode 100644 assets/cards/ace_of_diamonds.svg create mode 100644 assets/cards/ace_of_hearts.svg create mode 100644 assets/cards/ace_of_spades.svg create mode 100644 assets/cards/back.svg create mode 100644 assets/cards/back_blue.svg create mode 100644 assets/cards/back_red.svg create mode 100644 assets/cards/jack_of_clubs.svg create mode 100644 assets/cards/jack_of_diamonds.svg create mode 100644 assets/cards/jack_of_hearts.svg create mode 100644 assets/cards/jack_of_spades.svg create mode 100644 assets/cards/king_of_clubs.svg create mode 100644 assets/cards/king_of_diamonds.svg create mode 100644 assets/cards/king_of_hearts.svg create mode 100644 assets/cards/king_of_spades.svg create mode 100644 assets/cards/playing_cards_deck.svg create mode 100644 assets/cards/queen_of_clubs.svg create mode 100644 assets/cards/queen_of_diamonds.svg create mode 100644 assets/cards/queen_of_hearts.svg create mode 100644 assets/cards/queen_of_spades.svg create mode 100644 build.gradle.kts create mode 100644 engine/build.gradle.kts create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/OpponentModel.kt create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/Profiles.kt create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Card.kt create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/CardSource.kt create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Deck.kt create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/Equity.kt create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/HandEvaluator.kt create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/PreflopChart.kt create mode 100644 engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt create mode 100644 engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/HandEvaluatorTest.kt create mode 100644 engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/PreflopChartTest.kt create mode 100644 engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 sim/build.gradle.kts create mode 100644 sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt 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 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 zcma&NW0YlEwk;ePwr$(aux;D69T}N{9ky*d!_2U4+qUuIRNZ#Jck8}7U+vcB{`IjNZqX3eq5;s6ddAkU&5{L|^Ow`ym2B0m+K02+~Q)i807X3X94qi>j)C0e$=H zm31v`=T&y}ACuKx7G~yWSYncG=NFB>O2);i9EmJ(9jSamq?Crj$g~1l3m-4M7;BWn zau2S&sSA0b0Rhg>6YlVLQa;D#)1yw+eGs~36Q$}5?avIRne3TQZXb<^e}?T69w<9~ zUmx1cG0uZ?Kd;Brd$$>r>&MrY*3$t^PWF1+J+G_xmpHW=>mly$<>~wHH+Bt3mzN7W zhR)g{_veH6>*KxLJ~~s{9HZm!UeC86d_>42NRqd$ev8zSMq4kt)q*>8kJ8p|^wuKx zq2Is_HJPoQ_apSoT?zJj7vXBp!xejBc^7F|zU0rhy%Ub*Dy#jJs!>1?CmJ-gulPVX zKit>RVmjL=G?>jytf^U@mfnC*1-7EVag@%ROu*#kA+)Rxq?MGK0v-dp^kM?nyMngb z_poL>GLThB7xAO*I7&?4^Nj`<@O@>&0M-QxIi zD@n}s%CYI4Be19C$lAb9Bbm6!R{&A;=yh=#fnFyb`s7S5W3?arZf?$khCwkGN!+GY~GT8-`!6pFr zbFBVEF`kAgtecfjJ`flN2Z!$$8}6hV>Tu;+rN%$X^t8fI>tXQnRn^$UhXO8Gu zt$~QON8`doV&{h}=2!}+xJKrNPcIQid?WuHUC-i%P^F(^z#XB`&&`xTK&L+i8a3a@ zkV-Jy;AnyQ`N=&KONV_^-0WJA{b|c#_l=v!19U@hS~M-*ix16$r01GN3#naZ|DxY2 z76nbjbOnFcx4bKbEoH~^=EikiZ)_*kOb>nW6>_vjf-UCf0uUy~QBb7~WfVO6qN@ns zz=XEG0s5Yp`mlmUad)8!(QDgIzY=OK%_hhPStbyYYd|~zDIc3J4 zy9y%wZOW>}eG4&&;Z>vj&Mjg+>4gL! z(@oCTFf-I^54t=*4AhKRoE-0Ky=qg3XK2Mu!Bmw@z>y(|a#(6PcfbVTw-dUqyx4x4 z3O#+hW1ANwSv-U+9otHE#U9T>(nWx>^7RO_aI>${jvfZQ{mUwiaxHau!H z0Nc}ucJu+bKux?l!dQ2QA(r@(5KZl(Or=U!=2K*8?D=ZT-IAcAX!5OI3w@`sF@$($ zbDk0p&3X0P%B0aKdijO|s})70K&mk1DC|P##b=k@fcJ|lo@JNWRUc>KL?6dJpvtSUK zxR|w8Bo6K&y~Bd}gvuz*3z z@sPJr{(!?mi@okhudaM{t3gp9TJ!|@j4eO1C&=@h#|QLCUKLaKVL z!lls$%N&ZG7yO#jK?U>bJ+^F@K#A4d&Jz4boGmptagnK!Qu{Ob>%+60xRYK>iffd_ z>6%0K)p!VwP$^@Apm%NrS6TpKJwj_Q=k~?4=_*NIe~eh_QtRaqX4t-rJAGYdB{pGq zSXX)-dR8mQ)X|;8@_=J6Dk7MfMp;x)^aZeCtScHs12t3vL+p-6!qhPkOM1OYQ z8YXW5tWp)Th(+$m7SnV_hNGKAP`JF4URkkNc@YV9}FK$9k zR&qgi$Cj#4bC1VK%#U)f%(+oQJ+EqvV{uAq1YG0riLvGxW@)m;*ayU-BSW61COFy0 z(-l>GJqYl;*x1PnRZ(p3Lm}* zlkpWyCoYtg9pAZ5RU^%w=vN{3Y<6WImxj(*SCcJsFj?o6CZ~>cWW^foliM#qN#We{ zwsL!u1$rzC1#4~bILZm*a!T{^kCci$XOJADm)P;y^%x5)#G#_!2uNp^S;cE`*ASCn;}H7pP^RRA z6lfXK(r4dy<_}R|(7%Lyo>QFP#s31E8zsYA${gSUykUV@?lyDNF=KhTeF^*lu7C*{ zBCIjy;bIE;9inJ$IT8_jL%)Q{7itmncYlkf2`lHl(gTwD%LmEPo^gskydVxMd~Do` zO8EzF!yn!r|BEgPjhW#>g(unY#n}=#4J;3FD2ThN5LpO0tI2~pqICaFAGT%%;3Xx$ z>~Ng(64xH-RV^Rj4=A_q1Ee8kcF}8HN{5kjYX0ADh}jq{q18x(pV!23pVsK5S}{M#p8|+LvfKx|_3;9{+6cu7%5o-+R@z>TlTft#kcJ`s2-j zUe4dgpInZU!<}aTGuwgdWJZ#8TPiV9QW<-o!ibBn&)?!ZDomECehvT7GSCRyF#VN2&5GShch9*}4p;8TX~cW*<#( zv-HmU7&+YUWO__NN3UbTFJ&^#3vxW4U9q5=&ORa+2M$4rskA4xV$rFSEYBGy55b{z z!)$_fYXiY?-GWDhGZXgTw}#ilrw=BiN(DGO*W7Vw(} zjUexksYLt_Nq?pl_nVa@c1W#edQKbT>VSN1NK?DulHkFpI-LXl7{;dl@z0#v?x%U& z8k8M1X6%TwR4BQ_eEWJASvMTy?@fQubBU__A_US567I-~;_VcX^NJ-E(ZPR^NASj1 zVP!LIf8QKtcdeH#w6ak50At)e={eF_Ns6J2Iko6dn8Qwa6!NQHZMGsD zhzWeSFK<{hJV*!cIHxjgR+e#lkUHCss-j)$g zF}DyS531TUXKPPIoePo{yH%qEr-dLMOhv^sC&@9YI~uvl?rBp^A-57{aH_wLg0&a|UxKLlYZQ24fpb24Qjil`4OCyt0<1eu>5i1Acv zaZtQRF)Q;?Aw3idg;8Yg9Cb#)03?pQ@O*bCloG zC^|TnJl`GXN*8iI;Ql&_QIY0ik}rqB;cNZ-qagp=qmci9eScHsRXG$zRNdf4SleJ} z7||<#PCW~0>3u8PP=-DjNhD(^(B0AFF+(oKOiQyO5#v4nI|v_D5@c2;zE`}DK!%;H zUn|IZ6P;rl*5`E(srr6@-hpae!jW=-G zC<*R?RLwL;#+hxN4fJ!oP4fX`vC3&)o!#l4y@MrmbmL{t;VP%7tMA-&vju_L zhtHbOL4`O;h*5^e3F{b9(mDwY6JwL8w`oi28xOyj`pVo!75hngQDNg7^D$h4t&1p2 ziWD_!ap3GM(S)?@UwWk=Szym^eDxSx3NaR}+l1~(@0car6tfP#sZRTb~w!WAS{+|SgUN3Tv`J4OMf z9ta_f>-`!`I@KA=CXj_J>CE7T`yGmej0}61sE(%nZa1WC_tV6odiysHA5gzfWN-`uXF46mhJGLpvNTBmx$!i zF67bAz~E|P{L6t1B+K|Cutp&h$fDjyq9JFy$7c_tB(Q$sR)#iMQH3{Og1AyD^lyQwX6#B|*ecl{-_;*B>~WSFInaRE_q6 zpK#uCprrCb`MU^AGddA#SS{P7-OS9h%+1`~9v-s^{s8faWNpt*Pmk_ECjt(wrpr{C_xdAqR(@!ERTSs@F%^DkE@No}wqol~pS^e7>ksF_NhL0?6R4g`P- zk8lMrVir~b(KY+hk5LQngwm`ZQT5t1^7AzHB2My6o)_ejR0{VxU<*r-Gld`l6tfA` zKoj%x9=>Ce|1R|1*aC}|F0R32^KMLAHN}MA<8NNaZ^j?HKxSwxz`N2hK8lEb{jE0& zg4G_6F@#NyDN?=i@=)eidKhlg!nQoA{`PgaH{;t|M#5z}a`u?^gy{5L~I2smLR z*4RmNxHqf9>D>sXSemHK!h4uPwMRb+W`6F>Q6j@isZ>-F=)B2*sTCD9A^jjUy)hjAw71B&$u}R(^R; zY9H3k8$|ounk>)EOi_;JAKV8U8ICSD@NrqB!&=)Ah_5hzp?L9Sw@c>>#f_kUhhm=p z1jRz8X7)~|VwO(MF3PS(|CL++1n|KT3*dhGjg!t_vR|8Yg($ z+$S$K=J`K6eG#^(J54=4&X#+7Car=_aeAuC>dHE+%v9HFu>r%ry|rwkrO-XPhR_#K zS{2Unv!_CvS7}Mb6IIT$D4Gq5v$Pvi5nbYB+1Yc&RY;3;XDihlvhhIG6AhAHsBYsm zK@MgSzs~y|+f|j-lsXKT0(%E2SkEb)p+|EkV5w8=F^!r1&0#0^tGhf9yPZ)iLJ^ zIXOg)HW_Vt{|r0W(`NmMLF$?3ZQpq+^OtjR-DaVLHpz%1+GZ7QGFA?(BIqBlVQ;)k zu)oO|KG&++gD9oL7aK4Zwjwi~5jqk6+w%{T$1`2>3Znh=OFg|kZ z>1cn>CZ>P|iQO%-Pic8wE9c*e%=3qNYKJ+z1{2=QHHFe=u3rqCWNhV_N*qzneN8A5 zj`1Ir7-5`33rjDmyIGvTx4K3qsks(I(;Kgmn%p#p3K zn8r9H8kQu+n@D$<#RZtmp$*T4B&QvT{K&qx(?>t@mX%3Lh}sr?gI#vNi=vV5d(D<=Cp5-y!a{~&y|Uz*PU{qe zI7g}mt!txT)U(q<+Xg_sSY%1wVHy;Dv3uze zJ>BIdSB2a|aK+?o63lR8QZhhP)KyQvV`J3)5q^j1-G}fq=E4&){*&hiam>ssYm!ya z#PsY0F}vT#twY1mXkGYmdd%_Uh12x0*6lN-HS-&5XWbJ^%su)-vffvKZ%rvLHVA<; zJP=h13;x?$v30`T)M)htph`=if#r#O5iC^ZHeXc6J8gewn zL!49!)>3I-q6XOZRG0=zjyQc`tl|RFCR}f-sNtc)I^~?Vv2t7tZZHvgU2Mfc9$LqG z!(iz&xb=q#4otDBO4p)KtEq}8NaIVcL3&pbvm@0Kk-~C@y3I{K61VDF_=}c`VN)3P z+{nBy^;=1N`A=xH$01dPesY_na*zrcnssA}Ix60C=sWg9EY=2>-yH&iqhhm28qq9Z z;}znS4ktr40Lf~G@6D5QxW&?q^R|=1+h!1%G4LhQs54c2Wo~4% zCA||d==lv2bP=9%hd0Dw_a$cz9kk)(Vo}NpSPx!vnV*0Bh9$CYP~ia#lEoLRJ8D#5 zSJS?}ABn1LX>8(Mfg&eefX*c0I5bf4<`gCy6VC{e>$&BbwFSJ0CgVa;0-U7=F81R+ zUmzz&c;H|%G&mSQ0K16Vosh?sjJW(Gp+1Yw+Yf4qOi|BFVbMrdO6~-U8Hr|L@LHeZ z0ALmXHsVm137&xnt#yYF$H%&AU!lf{W436Wq87nC16b%)p?r z70Wua59%7Quak50G7m3lOjtvcS>5}YL_~?Pti_pfAfQ!OxkX$arHRg|VrNx>R_Xyi z`N|Y7KV`z3(ZB2wT9{Dl8mtl zg^UOBv~k>Z(E)O>Z;~Z)W&4FhzwiPjUHE9&T#nlM)@hvAZL>cha-< zQ8_RL#P1?&2Qhk#c9fK9+xM#AneqzE-g(>chLp_Q2Xh$=MAsW z2ScEKr+YOD*R~mzy{bOJjs;X2y1}DVFZi7d_df^~((5a2%p%^4cf>vM_4Sn@@ssVJ z9ChGhs zbanJ+h74)3tWOviXI|v!=HU2mE%3Th$Mpx&lEeGFEBWRy8ogJY`BCXj@7s~bjrOY! z4nIU5S>_NrpN}|waZBC)$6ST8x91U2n?FGV8lS{&LFhHbuHU?SVU{p7yFSP_f#Eyh zJhI@o9lAeEwbZYC=~<(FZ$sJx^6j@gtl{yTOAz`Gj!Ab^y})eG&`Qt2cXdog2^~oOH^K@oHcE(L;wu2QiMv zJuGdhNd+H{t#Tjd<$PknMSfbI>L1YIdZ+uFf*Z=BEM)UPG3oDFe@8roB0h(*XAqRc zoxw`wQD@^nxGFxQXN9@GpkLqd?9@(_ZRS@EFRCO8J5{iuNAQO=!Lo5cCsPtt4=1qZN8z`EA2{ge@SjTyhiJE%ttk{~`SEl%5>s=9E~dUW0uws>&~3PwXJ!f>ShhP~U9dLvE8ElNt3g(6-d zdgtD;rgd^>1URef?*=8BkE&+HmzXD-4w61(p6o~Oxm`XexcHmnR*B~5a|u-Qz$2lf zXc$p91T~E4psJxhf^rdR!b_XmNv*?}!PK9@-asDTaen;p{Rxsa=1E}4kZ*}yQPoT0 zvM}t!CpJvk<`m~^$^1C^o1yM(BzY-Wz2q7C^+wfg-?}1bF?5Hk?S{^#U%wX4&lv0j zkNb)byI+nql(&65xV?_L<0tj!KMHX8Hmh2(udEG>@OPQ}KPtdwEuEb$?acp~yT1&r z|7YU<(v!0as6Xff5^XbKQIR&MpjSE)pmub+ECMZzn7c!|hnm_Rl&H_oXWU2!h7hhf zo&-@cLkZr#eNgUN9>b=QLE1V^b`($EX3RQIyg#45A^=G!jMY`qJ z8qjZ$*-V|?y0=zIM>!2q!Gi*t4J5Otr^OT3XzQ_GjATc(*eM zqllux#QtHhc>YtnswBNiS^t(dTDn|RYSI%i%-|sv1wh&|9jfeyx|IHowW)6uZWR<%n8I}6NidBm zJ>P7#5m`gnXLu;?7jQZ!PwA80d|AS*+mtrU6z+lzms6^vc4)6Zf+$l+Lk3AsEK7`_ zQ9LsS!2o#-pK+V`g#3hC$6*Z~PD%cwtOT8;7K3O=gHdC=WLK-i_DjPO#WN__#YLX|Akw3LnqUJUw8&7pUR;K zqJ98?rKMXE(tnmT`#080w%l1bGno7wXHQbl?QFU=GoK@d!Ov=IgsdHd-iIs4ahcgSj(L@F96=LKZ zeb5cJOVlcKBudawbz~AYk@!^p+E=dT^UhPE`96Q5J~cT-8^tp`J43nLbFD*Nf!w;6 zs>V!5#;?bwYflf0HtFvX_6_jh4GEpa0_s8UUe02@%$w^ym&%wI5_APD?9S4r9O@4m zq^Z5Br8#K)y@z*fo08@XCs;wKBydn+60ks4Z>_+PFD+PVTGNPFPg-V-|``!0l|XrTyUYA@mY?#bJYvD>jX&$o9VAbo?>?#Z^c+Y4Dl zXU9k`s74Sb$OYh7^B|SAVVz*jEW&GWG^cP<_!hW+#Qp|4791Od=HJcesFo?$#0eWD z8!Ib_>H1WQE}shsQiUNk!uWOyAzX>r(-N7;+(O333_ES7*^6z4{`p&O*q8xk{0xy@ zB&9LkW_B}_Y&?pXP-OYNJfqEWUVAPBk)pTP^;f+75Wa(W>^UO_*J05f1k{ zd-}j!4m@q#CaC6mLsQHD1&7{tJ*}LtE{g9LB>sIT7)l^ucm8&+L0=g1E_6#KHfS>A_Z?;pFP96*nX=1&ejZ+XvZ=ML`@oVu>s^WIjn^SY}n zboeP%`O9|dhzvnw%?wAsCw*lvVcv%bmO5M4cas>b%FHd;A6Z%Ej%;jgPuvL$nk=VQ=$-OTwslYg zJQtDS)|qkIs%)K$+r*_NTke8%Rv&w^v;|Ajh5QXaVh}ugccP}3E^(oGC5VO*4`&Q0 z&)z$6i_aKI*CqVBglCxo#9>eOkDD!voCJRFkNolvA2N&SAp^4<8{Y;#Kr5740 za|G`dYGE!9NGU3Ge6C)YByb6Wy#}EN`Ao#R!$LQ&SM#hifEvZp>1PAX{CSLqD4IuO z4#N4AjMj5t2|!yTMrl5r)`_{V6DlqVeTwo|tq4MHLZdZc5;=v9*ibc;IGYh+G|~PB zx2}BAv6p$}?7YpvhqHu7L;~)~Oe^Y)O(G(PJQB<&2AhwMw!(2#AHhjSsBYUd8MDeM z+UXXyV@@cQ`w}mJ2PGs>=jHE{%i44QsPPh(=yorg>jHic+K+S*q3{th6Ik^j=@%xo zXfa9L_<|xTL@UZ?4H`$vt9MOF`|*z&)!mECiuenMW`Eo2VE#|2>2ET7th6+VAmU(o zq$Fz^TUB*@a<}kr6I>r;6`l%8NWtVtkE?}Q<<$BIm*6Z(1EhDtA29O%5d1$0q#C&f zFhFrrss{hOsISjYGDOP*)j&zZUf9`xvR8G)gwxE$HtmKsezo`{Ta~V5u+J&Tg+{bh zhLlNbdzJNF6m$wZNblWNbP6>dTWhngsu=J{);9D|PPJ96aqM4Lc?&6H-J1W15uIpQ ziO{&pEc2}-cqw+)w$`p(k(_yRpmbp-Xcd`*;Y$X=o(v2K+ISW)B1(ZnkV`g4rHQ=s z+J?F9&(||&86pi}snC07Lxi1ja>6kvnut;|Ql3fD)%k+ASe^S|lN69+Ek3UwsSx=2EH)t}K>~ z`Mz-SSVH29@DWyl`ChuGAkG>J;>8ZmLhm>uEmUvLqar~vK3lS;4s<{+ehMsFXM(l- zRt=HT>h9G)JS*&(dbXrM&z;)66C=o{=+^}ciyt8|@e$Y}IREAyd_!2|CqTg=eu}yG z@sI9T;Tjix*%v)c{4G84|0j@8wX^Iig_JsPU|T%(J&KtJ>V zsAR+dcmyT5k&&G{!)VXN`oRS{n;3qd`BgAE9r?%AHy_Gf8>$&X$=>YD7M911?<{qX zkJ;IOfY$nHdy@kKk_+X%g3`T(v|jS;>`pz`?>fqMZ>Fvbx1W=8nvtuve&y`JBfvU~ zr+5pF!`$`TUVsx3^<)48&+XT92U0DS|^X6FwSa-8yviRkZ*@Wu|c*lX!m?8&$0~4T!DB0@)n}ey+ew}T1U>|fH3=W5I!=nfoNs~OkzTY7^x^G&h>M7ewZqmZ=EL0}3#ikWg+(wuoA{7hm|7eJz zNz78l-K81tP16rai+fvXtspOhN-%*RY3IzMX6~8k9oFlXWgICx9dp;`)?Toz`fxV@&m8< z{lzWJG_Y(N1nOox>yG^uDr}kDX_f`lMbtxfP`VD@l$HR*B(sDeE(+T831V-3d3$+% zDKzKnK_W(gLwAK{Saa2}zaV?1QmcuhDu$)#;*4gU(l&rgNXB^WcMuuTki*rt>|M)D zoI;l$FTWIUp}euuZjDidpVw6AS-3dal2TJJaVMGj#CROWr|;^?q>PAo2k^u-27t~v zCv10IL~E)o*|QgdM!GJTaT&|A?oW)m9qk2{=y*7qb@BIAlYgDIe)k(qVH@)#xx6%7 z@)l%aJwz5Joc84Q2jRp71d;=a@NkjSdMyN%L6OevML^(L0_msbef>ewImS=+DgrTk z4ON%Y$mYgcZ^44O*;ctP>_7=}=pslsu>~<-bw=C(jeQ-X`kUo^BS&JDHy%#L32Cj_ zXRzDCfCXKXxGSW9yOGMMOYqPKnU zTF6gDj47!7PoL%z?*{1eyc2IVF*RXX?mj1RS}++hZg_%b@6&PdO)VzvmkXxJ*O7H} z6I7XmJqwX3<>z%M@W|GD%(X|VOZ7A+=@~MxMt8zhDw`yz?V>H%C0&VY+ZZ>9AoDVZeO1c~z$r~!H zA`N_9p`X?z>jm!-leBjW1R13_i2(0&aEY2$l_+-n#powuRO;n2Fr#%jp{+3@`h$c< zcFMr;18Z`UN#spXv+3Ks_V_tSZ1!FY7H(tdAk!v}SkoL9RPYSD3O5w>A3%>7J+C-R zZfDmu=9<1w1CV8rCMEm{qyErCUaA3Q zRYYw_z!W7UDEK)8DF}la9`}8z*?N32-6c-Bwx^Jf#Muwc67sVW24 zJ4nab%>_EM8wPhL=MAN)xx1tozAl zmhXN;*-X%)s>(L=Q@vm$qmuScku>PV(W_x-6E?SFRjSk)A1xVqnml_92fbj0m};UC zcV}lRW-r*wY106|sshV`n#RN{)D9=!>XVH0vMh>od=9!1(U+sWF%#B|eeaKI9RpaW z8Ol_wAJX%j0h5fkvF)WMZ1}?#R(n-OT0CtwsL)|qk;*(!a)5a5ku2nCR9=E*iOZ`9 zy4>LHKt-BgHL@R9CBSG!v4wK zvjF8DORRva)@>nshE~VM@i2c$PKw?3nz(6-iVde;-S~~7R<5r2t$0U8k2_<5C0!$j zQg#lsRYtI#Q1YRs(-%(;F-K7oY~!m&zhuU4LL}>jbLC>B`tk8onRRcmIm{{0cpkD|o@Ixu#x9Wm5J)3oFkbfi62BX8IX1}VTe#{C(d@H|#gy5#Sa#t>sH@8v1h8XFgNGs?)tyF_S^ueJX_-1%+LR`1X@C zS3Oc)o)!8Z9!u9d!35YD^!aXtH;IMNzPp`NS|EcdaQw~<;z`lmkg zE|tQRF7!S!UCsbag%XlQZXmzAOSs= zIUjgY2jcN9`xA6mzG{m|Zw=3kZC4@XY=Bj%k8%D&iadvne$pYNfZI$^2BAB|-MnZW zU4U?*qE3`ZDx-bH})>wz~)a z_SWM!E=-BS#wdrfh;EfPNOS*9!;*+wp-zDthj<>P0a2n?$xfe;YmX~5a;(mNV5nKx zYR86%WtAPsOMIg&*o9uUfD!v&4(mpS6P`bFohPP<&^fZzfA|SvVzPQgbtwwM>IO>Z z75ejU$1_SB1tn!Y-9tajZ~F=Fa~{cnj%Y|$;%z6fJV1XC0080f)Pj|87j142q6`i>#)BCIi+x&jAH9|H#iMvS~?w;&E`y zoarJ)+5HWmZ{&OqlzbdQU=SE3GKmnQq zI{h6f$C@}Mbqf#JDsJyi&7M0O2ORXtEB`#cZ;#AcB zkao0`&|iH8XKvZ_RH|VaK@tAGKMq9x{sdd%p-o`!cJzmd&hb86N!KKxp($2G?#(#BJn5%hF0(^`= z2qRg5?82({w-HyjbffI>eqUXavp&|D8(I6zMOfM}0;h%*D_Dr@+%TaWpIEQX3*$vQ z8_)wkNMDi{rW`L+`yN^J*Gt(l7PExu3_hrntgbW0s}7m~1K=(mFymoU87#{|t*fJ?w8&>Uh zcS$Ny$HNRbT!UCFldTSp2*;%EoW+yhJD8<3FUt8@XSBeJM2dSEz+5}BWmBvdYK(OA zlm`nDDsjKED{$v*jl(&)H7-+*#jWI)W|_X)!em1qpjS_CBbAiyMt;tx*+0P%*m&v< zxV9rlslu8#cS!of#^1O$(ds8aviMFiT`6W+FzMHW{YS+SieJ^?TQb%NT&pasw^kbc znd`=%(bebvrNx3#7vq@vAX-G`4|>cY0svIXopH02{v;GZ{wJM#psz4!m8(IZu<)9D zqR~U7@cz-6H{724_*}-DWwE8Sk+dYBb*O-=c z+wdchFcm6$$^Z0_qGnv0P`)h1=D$_eg8!2-|7Y;o*c)4ax!Me0*EVcioh{wI#!qcb z1&xhOotXMrlo7P6{+C8m;E#4*=8(2y!r0d<6 zKi$d2X;O*zS(&Xiz_?|`ympxITf|&M%^WHp=694g6W@k+BL_T1JtSYX0OZ}o%?Pzu zJ{%P8A$uq?4F!NWGtq>_GLK3*c6dIcGH)??L`9Av&0k$A*14ED9!e9z_SZd3OH6ER zg%5^)3^gw;4DFw(RC;~r`bPJOR}H}?2n60=g4ESUTud$bkBLPyI#4#Ye{5x3@Yw<* z;P5Up>Yn(QdP#momCf=kOzZYzg9E330=67WOPbCMm2-T1%8{=or9L8+HGL{%83lri zODB;Y|LS`@mn#Wmez7t6-x`a2{}U9hE|xY7|BVcFCqoAZQzsEi=dYHB z(bqG3J5?teVSBqTj{aiqe<9}}CEc$HdsJSMp#I;4(EXRy_k|Y8X#5hwkqAaIGKARF zX?$|UO{>3-FU;IlFi80O^t+WMNw4So2nsg}^T1`-Ox&C%Gn_AZ-49Nir=2oYX6 z`uVke@L5PVh)YsvAgFMZfKi{DuSgWnlAaag{RN6t6oLm6{4)H~4xg#Xfcq-e@ALk& z@UP4;uCe(Yjg4jaJZ4pu*+*?4#+XCi%sTrqaT*jNY7|WQ!oR;S8nt)cI27W$Sz!94 z01zoTW`C*P3E?1@6thPe(QpIue$A54gp#C7pmfwRj}GxIw$!!qQetn`nvuwIvMBQ; zfF8K-D~O4aJKmLbNRN1?AZsWY&rp?iy`LP^3KT0UcGNy=Z@7qVM(#5u#Du#w>a&Bs z@f#zU{wk&5n!YF%D11S9*CyaI8%^oX=vq$Ei9cL1&kvv9|8vZD;Mhs1&slm`$A%ED zvz6SQ8aty~`IYp2Xd~G$z%Jf4zwVPKkCtqObrnc2gHKj^jg&-NH|xdNK_;+2d4ZXw zN9j)`jcp7y65&6P@}LsD_OLSi(#GW#hC*qF5KpmeXuQDNS%ZYpuW<;JI<>P6ln!p@ z>KPAM>8^cX|2!n@tV=P)f2Euv?!}UM`^RJ~nTT@W>KC2{{}xXS{}WH{|3najkiEUj z7l;fUWDPCtzQ$?(f)6RvzW~Tqan$bXibe%dv}**BqY!d4J?`1iX`-iy8nPo$s4^mQ z5+@=3xuZAl#KoDF*%>bJ4UrEB2EE8m7sQn!r7Z-ggig`?yy`p~3;&NFukc$`_>?}a z?LMo2LV^n>m!fv^HKKRrDn|2|zk?~S6i|xOHt%K(*TGWkq3{~|9+(G3M-L=;U-YRa zp{kIXZ8P!koE;BN2A;nBx!={yg4v=-xGOMC#~MA07zfR)yZtSF_2W^pDLcXg->*WD zY7Sz5%<_k+lbS^`y)=vX|KaN!gEMQob|(`%nP6huwr$%^?%0^vwr$(CZQD*Jc5?E( zb-q9E`OfoWSJ$rUs$ILfSFg3Mb*-!Ozgaz^%7ZkX@=3km0G;?+e?FQT_l5A9vKr<> z_CoemDo@6YIyl57l*gnJ^7+8xLW5oEGzjLv2P8vj*Q%O1^KOfrsC6eHvk{+$BMLGu z%goP8UY?J7Lj=@jcI$4{m2Sw?1E%_0C7M$lj}w{E#hM4%3QX|;tH6>RJf-TI_1A0w z@KcTEFx(@uitbo?UMMqUaSgt=n`Bu*;$4@cbg9JIS})3#2T;B7S

Z?HZkSa`=MM?n)?|XcM)@e1qmzJ$_4K^?-``~Oi&38`2}sjmP?kK z$yT)K(UU3fJID@~3R;)fU%k%9*4f>oq`y>#t90$(y*sZTzWcW$H=Xv|%^u^?2*n)Csx;35O0v7Nab-REgxDZNf5`cI69k$` zx(&pP6zVxlK5Apn5hAhui}b)(IwZD}D?&)_{_yTL7QgTxL|_X!o@A`)P#!%t9al+# zLD(Rr+?HHJEOl545~m1)cwawqY>cf~9hu-L`crI^5p~-9Mgp9{U5V&dJSwolnl_CM zwAMM1Tl$D@>v?LN2PLe0IZrQL1M zcA%i@Lc)URretFJhtw7IaZXYC6#8slg|*HfUF2Z5{3R_tw)YQ94=dprT`SFAvHB+7 z)-Hd1yE8LB1S+4H7iy$5XruPxq6pc_V)+VO{seA8^`o5{T5s<8bJ`>I3&m%R4cm1S z`hoNk%_=KU2;+#$Y!x7L%|;!Nxbu~TKw?zSP(?H0_b8Qqj4EPrb@~IE`~^#~C%D9k zvJ=ERh`xLgUwvusQbo6S=I5T+?lITYsVyeCCwT9R>DwQa&$e(PxF<}RpLD9Vm2vV# zI#M%ksVNFG1U?;QR{Kx2sf>@y$7sop6SOnBC4sv8S0-`gEt0eHJ{`QSW(_06Uwg*~ zIw}1dZ9c=K$a$N?;j`s3>)AqC$`ld?bOs^^stmYmsWA$XEVhUtGlx&OyziN1~2 z)s5fD(d@gq7htIGX!GCxKT=8aAOHW&DAP=$MpZ)SpeEZhk83}K) z0(Uv)+&pE?|4)D2PX4r6gOGHDY}$8FSg$3eDb*nEVmkFQ#lFpcH~IPeatiH3nPTkP z*xDN7l}r2GM9jwSsl=*!547nRPCS0pb;uE#myTqV+=se>bU=#e)f2}wCp%f-cIrh`FHA$2`monVy?qvJ~o2B6I7IE28bCY4=c#^){*essLG zXUH50W&SWmi{RIG9G^p;PohSPtC}djjXSoC)kyA8`o+L}SjE{i?%;Vh=h;QC{s`T7 zLmmHCr8F}#^O8_~lR)^clv$mMe`e*{MW#Sxd`rDckCnFBo9sC*vw2)dA9Q3lUi*Fy zgDsLt`xt|7G=O6+ms=`_FpD4}37uvelFLc^?snyNUNxbdSj2+Mpv<67NR{(mdtSDNJ3gSD@>gX_7S5 zCD)JP5Hnv!llc-9fwG=4@?=%qu~(4j>YXtgz%gZ#+A9i^H!_R!MxWlFsH(ClP3dU} za&`m(cM0xebj&S170&KLU%39I+XVWOJ_1XpF^ip}3|y()Fn5P@$pP5rvtiEK6w&+w z7uqIxZUj$#qN|<_LFhE@@SAdBy8)xTu>>`xC>VYU@d}E)^sb9k0}YKr=B8-5M?3}d z7&LqQWQ`a&=ihhANxe3^YT>yj&72x#X4NXRTc#+sk;K z=VUp#I(YIRO`g7#;5))p=y=MQ54JWeS(A^$qt>Y#unGRT$0BG=rI(tr>YqSxNm+-x z6n;-y8B>#FnhZX#mhVOT30baJ{47E^j-I6EOp;am;FvTlYRR2_?CjCWY+ypoUD-2S zqnFH6FS+q$H$^7>>(nd^WE+?Zn#@HU3#t|&=JnEDgIU+;CgS+krs+Y8vMo6U zHVkPoReZ-Di3z!xdBu#aW1f{8sC)etjN90`2|Y@{2=Os`(XLL9+ z1$_PE$GgTQrVx`^sx=Y(_y-SvquMF5<`9C=vM52+e+-r=g?D z+E|97MyoaK5M^n1(mnWeBpgtMs8fXOu4Q$89C5q4@YY0H{N47VANA1}M2e zspor6LdndC=kEvxs3YrPGbc;`q}|zeg`f;t3-8na)dGdZ9&d(n{|%mNaHaKJOA~@8 zgP?nkzV-=ULb)L3r`p)vj4<702a5h~Y%byo4)lh?rtu1YXYOY+qyTwzs!59I zL}XLe=q$e<+Wm7tvB$n88#a9LzBkgHhfT<&i#%e*y|}@I z!N~_)vodngB7%CI2pJT*{GX|cI5y>ZBN)}mezK~fFv@$*L`84rb0)V=PvQ2KN}3lTpT@$>a=CP?kcC0S_^PZ#Vd9#CF4 zP&`6{Y!hd^qmL!zr#F~FB0yag-V;qrmW9Jnq~-l>Sg$b%%TpO}{Q+*Pd-@n2suVh_ zSYP->P@# z&gQ^f{?}m(u5B9xqo63pUvDsJDQJi5B~ak+J{tX8$oL!_{Dh zL@=XFzWb+83H3wPbTic+osVp&~UoW3SqK0#P6+BKbOzK65tz)-@AW#g}Ew+pE3@ zVbdJkJ}EM@-Ghxp_4a)|asEk* z5)mMI&EK~BI^aaTMRl)oPJRH^Ld{;1FC&#pS`gh;l3Y;DF*`pR%OSz8U@B@zJxPNX zwyP_&8GsQ7^eYyUO3FEE|9~I~X8;{WTN=DJW0$2OH=3-!KZG=X6TH?>URr(A0l@+d zj^B9G-ACel;yYGZc}G`w9sR$Mo{tzE7&%XKuW$|u7DM<6_z}L>I{o`(=!*1 z{5?1p3F^aBONr6Ws!6@G?XRxJxXt_6b}2%Bp=0Iv5ngnpU^P+?(?O0hKwAK z*|wAisG&8&Td1XY+6qI~-5&+4DE2p|Dj8@do;!40o)F)QuoeUY;*I&QZ0*4?u)$s`VTkNl1WG`}g@J_i zjjmv4L%g&>@U9_|l>8^CN}`@4<D2aMN&?XXD-HNnsVM`irjv$ z^YVNUx3r1{-o6waQfDp=OG^P+vd;qEvd{UUYc;gF0UwaeacXkw32He^qyoYHjZeFS zo(#C9#&NEdFRcFrj7Q{CJgbmDejNS!H%aF6?;|KJQn_*Ps3pkq9yE~G{0wIS*mo0XIEYH zzIiJ>rbmD;sGXt#jlx7AXSGGcjty)5z5lTGp|M#5DCl0q0|~pNQ%1dP!-1>_7^BA~ zwu+uumJmTCcd)r|Hc)uWm7S!+Dw4;E|5+bwPb4i17Ued>NklnnsG+A{T-&}0=sLM- zY;sA9v@YH>b9#c$Vg{j@+>UULBX=jtu~N^%Y#BB5)pB|$?0Mf7msMD<7eACoP1(XY zPO^h5Brvhn$%(0JSo3KFwEPV&dz8(P41o=mo7G~A*P6wLJ@-#|_A z7>k~4&lbqyP1!la!qmhFBfIfT?nIHQ0j2WlohXk^sZ`?8-vwEwV0~uu{RDE^0yfl$ znua{^`VTZ)-h#ch_6^e2{VPaE@o&55|3dx$z_b6gbqduXJ(Lz(zq&ZbJ6qA4Ac4RT zhJO4KBLN!t;h(eW(?cZJw^swf8lP@tWMZ8GD)zg)siA3!2EJYI(j>WI$=pK!mo!Ry z?q&YkTIbTTr<>=}+N8C_EAR0XQL2&O{nNAXb?33iwo8{M``rUHJgnk z8KgZzZLFf|(O6oeugsm<;5m~4N$2Jm5#dph*@TgXC2_k&d%TG0LPY=Fw)=gf(hy9QmY*D6jCAiq44 zo-k2C+?3*+Wu7xm1w*LEAl`Vsq(sYPUMw|MiXrW)92>rVOAse5Pmx^OSi{y%EwPAE zx|csvE{U3c{vA>@;>xcjdCW15pE31F3aoIBsz@OQRvi%_MMfgar2j3Ob`9e@gLQk# zlzznEHgr|Ols%f*a+B-0klD`czi@RWGPPpR1tE@GB|nwe`td1OwG#OjGlTH zfT#^r?%3Ocp^U0F8Kekck6-Vg2gWs|sD_DTJ%2TR<5H3a$}B4ZYpP=p)oAoHxr8I! z1SYJ~v-iP&mNm{ra7!KP^KVpkER>-HFvq*>eG4J#kz1|eu;=~u2|>}TE_5nv2=d!0 z3P~?@blSo^uumuEt{lBsGcx{_IXPO8s01+7DP^yt&>k;<5(NRrF|To2h7hTWBFQ_A z+;?Q$o5L|LlIB>PH(4j)j3`JIb1xA_C@HRFnPnlg{zGO|-RO7Xn}!*2U=Z2V?{5Al z9+iL+n^_T~6Uu{law`R&fFadSVi}da8G>|>D<{(#vi{OU;}1ZnfXy8=etC7)Ae<2S zAlI`&=HkNiHhT0|tQztSLNsRR6v8bmf&$6CI|7b8V4kyJ{=pG#h{1sVeC28&Ho%Fh zwo_FIS}ST-2OF6jNQ$(pjrq)P)@sie#tigN1zSclxJLb-O9V|trp^G8<1rpsj8@+$ z2y27iiM>H8kfd%AMlK|9C>Lkvfs9iSk>k2}tCFlqF~Z_>-uWVQDd$5{3sM%2$du9; z*ukNSo}~@w@DPF)_vS^VaZ)7Mk&8ijX2hNhKom$#PM%bzSA-s$ z0O!broj`!Nuk)Qcp3(>dL|5om#XMx2RUSDMDY9#1|+~fxwP}1I4iYy4j$CGx3jD&eKhf%z`Jn z7mD!y6`nVq%&Q#5yqG`|+e~1$Zkgu!O(~~pWSDTw2^va3u!DOMVRQ8ycq)sk&H%vb z;$a`3gp74~I@swI!ILOkzVK3G&SdTcVe~RzN<+z`u(BY=yuwez{#T3a_83)8>2!X?`^02zVjqx-fN+tW`zCqH^XG>#Ies$qxa!n4*FF0m zxgJlPPYl*q4ylX;DVu3G*I6T&JyWvs`A(*u0+62=+ylt2!u)6LJ=Qe1rA$OWcNCmH zLu7PwMDY#rYQA1!!ONNcz~I^uMvi6N&Lo4dD&HF?1Su5}COTZ-jwR)-zLq=6@bN}X zSP(-MY`TOJ@1O`bLPphMMSWm+YL{Ger>cA$KT~)DuTl+H)!2Lf`c+lZ0ipxd>KfKn zIv;;eEmz(_(nwW24a+>v{K}$)A?=tp+?>zAmfL{}@0r|1>iFQfJ5C*6dKdijK=j16 zQpl4gl93ttF5@d<9e2LoZ~cqkH)aFMgt(el_)#OG4R4Hnqm(@D*Uj>2ZuUCy)o-yy z_J|&S-@o5#2IMcL(}qWF3EL<4n(`cygenA)G%Ssi7k4w)LafelpV5FvS9uJES+(Ml z?rzZ={vYrB#mB-Hd#ID{KS5dKl-|Wh_~v+Lvq3|<@w^MD-RA{q!$gkUUNIvAaex5y z)jIGW{#U=#UWyku7FIAB=TES8>L%Y9*h2N`#Gghie+a?>$CRNth?ORq)!Tde24f5K zKh>cz5oLC;ry*tHIEQEL>8L=zsjG7+(~LUN5K1pT`_Z-4Z}k^m%&H%g3*^e(FDCC{ zBh~eqx%bY?qqu_2qa+9A+oS&yFw^3nLRsN#?FcZvt?*dZhRC_a%Jd{qou(p5AG_Q6 ziOJMu8D~kJ7xEkG(69$Dl3t1J592=Olom%;13uZvYDda08YwzqFlND-;YodmA!SL) z!AOSI=(uCnG#Yo&BgrH(muUemmhQW7?}IHfxI~T`44wuLGFOMdKreQO!a=Z-LkH{T z@h;`A_l2Pp>Xg#`Vo@-?WJn-0((RR4uKM6P2*^-qprHgQhMzSd32@ho>%fFMbp9Y$ zx-#!r8gEu;VZN(fDbP7he+Nu7^o3<+pT!<<>m;m z=FC$N)wx)asxb_KLs}Z^;x*hQM}wQGr((&=%+=#jW^j|Gjn$(qqXwt-o-|>kL!?=T zh0*?m<^>S*F}kPiq@)Cp+^fnKi2)%<-Tw4K3oHwmI-}h}Kc^+%1P!D8aWp!hB@-ZT zybHrRdeYlYulEj>Bk zEIi|PU0eGg&~kWQ{q)gw%~bFT0`Q%k5S|tt!JIZXVXX=>er!7R^w>zeQ%M-(C|eOQG>5i|}i3}X#?aqAg~b1t{-fqwKd(&CyA zmyy)et*E}+q_lEqgbClewiJ=u@bFX}LKe)5o26K9fS;R`!er~a?lUCKf60`4Zq7{2q$L?k?IrAdcDu+ z4A0QJBUiGx&$TBASI2ASM_Wj{?fjv=CORO3GZz;1X*AYY`anM zI`M6C%8OUFSc$tKjiFJ|V74Yj-lK&Epi7F^Gp*rLeDTokfW#o6sl33W^~4V|edbS1 zhx%1PTdnI!C96iYqSA=qu6;p&Dd%)Skjjw0fyl>3k@O?I@x5|>2_7G#_Yc2*1>=^# z|H43bJDx$SS2!vkaMG!;VRGMbY{eJhT%FR{(a+RXDbd4OT?DRoE(`NhiVI6MsUCsT z1gc^~Nv>i;cIm2~_SYOfFpkUvV)(iINXEep;i4>&8@N#|h+_;DgzLqh3I#lzhn>cN zjm;m6U{+JXR2Mi)=~WxM&t9~WShlyA$Pnu+VIW2#;0)4J*C!{1W|y1TP{Q;!tldR< zI7aoH&cMm*apW}~BabBT;`fQ1-9q|!?6nTzmhiIo6fGQlcP{pu)kJh- zUK&Ei9lArSO6ep_SN$Lt_01|Y#@Ksznl@f<+%ku1F|k#Gcwa`(^M<2%M3FAZVb99?Ez4d9O)rqM< zCbYsdZlSo{X#nKqiRA$}XG}1Tw@)D|jGKo1ITqmvE4;ovYH{NAk{h8*Ysh@=nZFiF zmDF`@4do#UDKKM*@wDbwoO@tPx4aExhPF_dvlR&dB5>)W=wG6Pil zq{eBzw%Ov!?D+%8&(uK`m7JV7pqNp-krMd>ECQypq&?p#_3wy){eW{(2q}ij{6bfmyE+-ZO z)G4OtI;ga9;EVyKF6v3kO1RdQV+!*>tV-ditH-=;`n|2T zu(vYR*BJSBsjzFl1Oy#DpL=|pfEY4NM;y5Yly__T*Eg^3Mb_()pHwn)mAsh!7Yz-Z zY`hBLDXS4F^{>x=oOphq|LMo;G!C(b2hS9A6lJqb+e$2af}7C>zW2p{m18@Bdd>iL zoEE$nFUnaz_6p${cMO|;(c1f9nm5G5R;p)m4dcC1?1YD=2Mi&20=4{nu>AV#R^d%A zsmm_RlT#`;g~an9mo#O1dYV)2{mgUWEqb*a@^Ok;ckj;uqy{%*YB^({d{^V)P9VvP zC^qbK&lq~}TWm^RF8d4zbo~bJuw zFV!!}b^4BlJ0>5S3Q>;u*BLC&G6Fa5V|~w&bRZ*-YU>df6%qAvK?%Qf+#=M-+JqLw&w*l4{v7XTstY4j z26z69U#SVzSbY9HBXyD;%P$#vVU7G*Yb-*fy)Qpx?;ed;-P24>-L6U+OAC9Jj63kg zlY`G2+5tg1szc#*9ga3%f9H9~!(^QjECetX-PlacTR+^g8L<#VRovPGvsT)ln3lr= zm5WO@!NDuw+d4MY;K4WJg3B|Sp|WdumpFJO>I2tz$72s4^uXljWseYSAd+vGfjutO z-x~Qlct+BnlI+Iun)fOklxPH?30i&j9R$6g5^f&(x7bIom|FLKq9CUE);w2G>}vye zxWvEaXhx8|~2j)({Rq>0J9}lzdE`yhQ(l$z! z;x%d%_u?^4vlES_>JaIjJBN|N8z5}@l1#PG_@{mh`oWXQOI41_kPG}R_pV+jd^PU) zEor^SHo`VMul*80-K$0mSk|FiI+tHdWt-hzt~S>6!2-!R&rdL_^gGGUzkPe zEZkUKU=EY(5Ex)zeTA4-{Bkbn!Gm?nuaI4jLE%X;zMZ7bwn4FXz(?az;9(Uv;38U6 zi)}rA3xAcD2&6BY<~Pj9Q1~4Dyjs&!$)hyHiiTI@%qXd~+>> zW}$_puSSJ^uWv$jtWakn}}@eX6_LGz|7M#$!3yjY ztS{>HmQ%-8u0@|ig{kzD&CNK~-dIK5e{;@uWOs8$r>J7^c2P~Pwx%QVX0e8~oXK0J zM4HCNK?%t6?v~#;eP#t@tM$@SXRt;(b&kU7uDzlzUuu;+LQ5g%=FqpJPGrX8HJ8CS zITK|(fjhs3@CR}H4@)EjL@J zV_HPexOQ!@k&kvsQG)n;7lZaUh>{87l4NS_=Y-O9Ul3CaKG8iy+xD=QXZSr57a-hb z7jz3Ts-NVsMI783OPEdlE|e&a2;l^h@e>oYMh5@=Lte-9A+20|?!9>Djl~{XkAo>0p9`n&nfWGdGAfT-mSYW z1cvG>GT9dRJdcm7M_AG9JX5AqTCdJ6MRqR3p?+FvMxp(oB-6MZ`lRzSAj%N(1#8@_ zDnIIo9Rtv12(Eo}k_#FILhaZQ`yRD^Vn5tm+IK@hZO>s=t5`@p1#k?Umz2y*R64CF zGM-v&*k}zZ%Xm<_?1=g~<*&3KAy;_^QfccIp~CS7NW24Tn|mSDxb%pvvi}S}(~`2# z3I|kD@||l@lAW06K2%*gHd4x9YKeXWpwU%!ozYcJ+KJeX!s6b94j!Qyy7>S!wb?{qaMa`rpbU1phn0EpF}L zsBdZc|Im#iRiQmJjZwb5#n;`_O{$Zu$I zMXqbfu0yVmt!!Y`Fzl}QV7HUSOPib#da4i@vM$0u2FEYytsvrbR#ui9lrMkZ(AVVJ zMVl^Wi_fSRsEXLA_#rdaG%r(@UCw#o7*yBN)%22b)VSNyng6Lxk|2;XK3Qb=C_<`F zN##8MLHz-s%&O6JE~@P1=iHpj8go@4sC7*AWe99tuf$f7?2~wC&RA^UjB*2`K!%$y zSDzMd7}!vvN|#wDuP%%nuGk8&>N)7eRxtqdMXHD1W%hP7tYW{W>^DJp`3WS>3}i+$ z_li?4AlEj`r=!SPiIc+NNUZ9NCrMv&G0BdQHBO&S7d48aB)LfGi@D%5CC1%)1hVcJ zB~=yNC}LBn(K?cHkPmAX$5^M7JSnNkcc!X!0kD&^F$cJmRP(SJ`9b7}b)o$rj=BZ- zC;BX3IG94%Qz&(V$)7O~v|!=jd-yU1(6wd1u;*$z4DDe6+BFLhz>+8?59?d2Ngxck zm92yR!jk@MP@>>9FtAY2L+Z|MaSp{MnL-;fm}W3~fg!9TRr3;S@ysLf@#<)keHDRO zsJI1tP`g3PNL`2(8hK3!4;r|E-ZQbU0e-9u{(@du`4wjGj|A!QB&9w~?OI1r}M? zw)6tvsknfPfmNijZ;3VZX&HM6=|&W zy6GIe3a?_(pRxdUc==do9?C&v7+6cgIoL4)Ka^bOG9`l;S|QmVzjv%)3^PDi@=-cp z=!R0bU<@_;#*D}e1m@0!%k=VPtyRAkWYW(VFl|eu0LteWH7eDB%P|uF7BQ-|D4`n; z)UpuY1)*s32UwW756>!OoAq#5GAtfrjo*^7YUv^(eiySE?!TQzKxzqXE@jM_bq3Zq zg#1orE*Zd5ZWEpDXW9$=NzuadNSO*NW)ZJ@IDuU`w}j_FRE4-QS*rD4mPVQPH(jGg z+-Ye?3%G%=DT5U1b+TnNHHv(nz-S?3!M4hXtEB@J4WK%%p zkv=Bb`1DHmgUdYo>3kwB(T>Ba#DKv%cLp2h4r8v}p=Np}wL!&PB5J-w4V4REM{kMD z${oSuAw9?*yo3?tNp~X5WF@B^P<6L0HtIW0H7^`R8~9zAXgREH`6H{ntGu$aQ;oNq zig;pB^@KMHNoJcEb0f1fz+!M6sy?hQjof-QoxJgBM`!k^T~cykcmi^s_@1B9 z)t1)Y-ZsV9iA&FDrVoF=L7U#4&inXk{3+Xm9A|R<=ErgxPW~Fq zqu-~x0dIBlR+5_}`IK^*5l3f5$&K@l?J{)_d_*459pvsF*e*#+2guls(cid4!N%DG zl3(2`az#5!^@HNRe3O4(_5nc+){q?ENQG2|uKW0U0$aJ5SQ6hg>G4OyN6os76y%u8qNNHi;}XnRNwpsfn^!6Qt(-4tE`uxaDZ`hQp#aFX373|F?vjEiSEkV>K)cTBG+UL#wDj0_ zM9$H&-86zP=9=5_Q7d3onkqKNr4PAlF<>U^^yYAAEso|Ak~p$3NNZ$~4&kE9Nj^As zQPoo!m*uZ;z1~;#g(?zFECJ$O2@EBy<;F)fnQxOKvH`MojG5T?7thbe%F@JyN^k1K zn3H*%Ymoim)ePf)xhl2%$T)vq3P=4ty%NK)@}po&7Q^~o3l))Zm4<75Y!fFihsXJc z9?vecovF^nYfJVg#W~R3T1*PK{+^YFgb*7}Up2U#)oNyzkfJ#$)PkFxrq_{Ai?0zk zWnjq_ixF~Hs7YS9Y6H&8&k0#2cAj~!Vv4{wCM zi2f1FjQf+F@=BOB)pD|T41a4AEz+8hnH<#_PT#H|Vwm7iQ0-Tw()WMN za0eI-{B2G{sZ7+L+^k@BA)G;mOFWE$O+2nS|DzPSGZ)ede(9%+8kqu4W^wTn!yZPN z7u!Qu0u}K5(0euRZ$7=kn9DZ+llruq5A_l) zOK~wof7_^8Yeh@Qd*=P!gM)lh`Z@7^M?k8Z?t$$vMAuBG>4p56Dt!R$p{)y>QG}it zGG;Ei```7ewXrbGo6Z=!AJNQ!GP8l13m7|FIQTFZTpIg#kpZkl1wj)s1eySXjAAWy zfl;;@{QQ;Qnb$@LY8_Z&7 z6+d98F?z2Zo)sS)z$YoL(zzF>Ey8u#S_%n7)XUX1Pu(>e8gEUU1S;J=EH(#`cWi1+ zoL$5TN+?#NM8=4E7HOk)bf5MXvEo%he5QcB%_5YQ$cu_j)Pd^@5hi}d%nG}x9xXtD-JMQxr;KkC=r_dS-t`lf zF&CS?Lk~>U^!)Y0LZqNVJq+*_#F7W~!UkvZfQhzvW`q;^X&iv~ zEDDGIQ&(S;#Hb(Ej4j+#D#sDS_uHehlY0kZsQpktc?;O z22W1b%wNcdfNza<1M2{*mAkM<{}@(w`VuQ<^lG|iYSuWBD#lYK9+jsdA+&#;Y@=zXLVr840Nq_t5))#7}2s9pK* zg42zd{EY|#sIVMDhg9>t6_Y#O>JoG<{GO&OzTa;iA9&&^6=5MT21f6$7o@nS=w;R) znkgu*7Y{UNPu7B9&B&~q+N@@+%&cO0N`TZ-qQ|@f@e0g2BI+9xO$}NzMOzEbSSJ@v z1uNp(S z-dioXc$5YyA6-My@gW~1GH($Q?;GCHfk{ej-{Q^{iTFs1^Sa67RNd5y{cjX1tG+$& zbGrUte{U1{^Z_qpzW$-V!pJz$dQZrL5i(1MKU`%^= z^)i;xua4w)evDBrFVm)Id5SbXMx2u7M5Df<2L4B`wy4-Y+Wec#b^QJO|J9xF{x#M8 zuLUer`%ZL^m3gy?U&dI+`kgNZ+?bl3H%8)&k84*-=aMfADh&@$xr&IS|4{3$v&K3q zZTn&f{N(#L6<-BZYNs4 zB*Kl*@_IhGXI^_8zfXT^XNmjJ@5E~H*wFf<&er?p7suz85)$-Hqz@C zGMFg1NKs;otNViu)r-u{SOLcqwqc7$poPvm(-^ag1m71}HL#cj5t4Hw(W?*fi4GSH z9962NZ>p^ECPqVc$N}phy>N8rQsWWm%%rc5B4XLATFEtffX&TM2%|8S2Lh_q; zCytXua84HBnSybW-}(j z3Zwv4CaK)jC!{oUvdsFRXK&Sx@t)yGm(h65$!WZ!-jL52no}NX6=E<=H!aZ74h_&> zZ+~c@k!@}Cs84l{u+)%kg4fq~pOeTK3S4)gX~FKJw4t9ba!Ai{_gkKQYQvafZIyKq zX|r4xgC(l%JgmW!tvR&yNt$6uME({M`uNIi7HFiPEQo_UMRkl~12&4c& z^se;dbZWKu7>dLMg`IZq%@b@ME?|@{&xEIZEU(omKNUY? z`JszxNghuO-VA;MrZKEC0|Gi0tz3c#M?aO?WGLy64LkG4T%|PBIt_?bl{C=L@9e;A zia!35TZI7<`R8hr06xF62*rNH5T3N0v^acg+;ENvrLYo|B4!c^eILcn#+lxDZR!%l zjL6!6h9zo)<5GrSPth7+R(rLAW?HF4uu$glo?w1U-y}CR@%v+wSAlsgIXn>e%bc{FE;j@R0AoNIWf#*@BSngZ)HmNqkB z)cs3yN%_PT4f*K+Y1wFl)be=1iq+bb1G-}b|72|gJ|lMt`tf~0Jk}zMbS0+M-Mq}R z>Bv}-W6J%}j#dIz`Z0}zD(DGKn`R;E8A`)$a6qDfr(c@iHKZcCVY_nJEDpcUddGH* z*ct2$&)RelhmV}@jGXY>3Y~vp;b*l9M+hO}&x`e~q*heO8GVkvvJTwyxFetJC8VnhjR`5*+qHEDUNp16g`~$TbdliLLd}AFf}U+Oda1JXwwseRFbj?DN96;VSX~z?JxJSuA^BF}262%Z0)nv<6teKK`F zfm9^HsblS~?Xrb1_~^=5=PD!QH$Y1hD_&qe1HTQnese8N#&C(|Q)CvtAu6{{0Q%ut8ESVdn&& z4y%nsCs!$(#9d{iVjXDR##3UyoMNeY@_W^%qyuZ^K3Oa4(^!tDXOUS?b2P)yRtJ8j zSX}@qGBj+gKf;|6Kb&rq`!}S*cSu-3&S>=pM$eEB{K>PP~I}N|uGE|`3U#{Q6v^kO4nIsaq zfPld}c|4tVPI4!=!ETCNW+LjcbmEoxm0RZ%ieV0`(nVlWKClZW5^>f&h79-~CF(%+ zv|KL(^xQ7$#a}&BSGr9zf{xJ(cCfq>UR*>^-Ou_pmknCt6Y--~!duL{k2D{yLMl__ z!KeMRRg&EsD2s|cmy?xgK&XcGIKeos`&UEVhBTw;mqy|8DlP1M7PYS2z{YmTJ;n!h znPe(Qu?c7+xZz!Tm1AnE8|;&tf7fW$2dArX7ck1Jd(S1+91YB8bjISRZ`UL*?vb{b zMp*!Xq7VaLc0Ogqj5qmop8NREQ{9_iC$;tviZlubGLy1jLlIFBxAymMr@SDLAcx+) z5YRkl$bW**X)W0JzWNcLx9>fTqJj00ipY6Ua?mUlsgQrVVgpmaheE;RgA5U_+WsPh z9+X|PU4zFyNxZ2?Q+V`Mo{xH~(m}OMRZa<&$nCl7o4x`^^|V4?aPz8#KwFm=8T6_} z8=P_4$_rD2a%7}}HT6VQ>ZGKW=QF7zI-2=6oBNZR$HVn|gq`>l$HZ`48lkM7%R$>MS& zghR`WZ9Xrd_6FaDedH6_aKVJhYev*2)UQ>!CRH3PQ_d9nXlO;c z9PeqiKD@aGz^|mvD-tV<{BjfA;)B+76!*+`$CZOJ=#)}>{?!9fAg(Xngbh||n=q*C zU0mGP`NxHn$uY#@)gN<0xr)%Ue80U{-`^FX1~Q@^>WbLraiB|c#4v$5HX)0z!oA#jOXPyWg! z8EC}SBmG7j3T&zCenPLYA{kN(3l62pu}91KOWZl? zg~>T4gQ%1y3AYa^J|>ba$7F5KlVx}_&*~me*q-SYLBCXZFU=U8mHQD4K!?;B61NoX z?VS41SS&jHyhmB~+bC=w0a06V``ZXCkC~}oM9pM{$hU~-s_elYPmT1L!%B`?*<+?( zFQ@TP%y+QL`_&Y0A3679pe5~iL=z)$b)k!oSbJRyw+K};SGAvvE=|<~*aiwJc?uE@2?7a1i9|3=^N%*9smt3ZIhjY>gIsr{Q2rX(NovZ7I1n^V{ z#~(1ze-%`C>fM`^hCV**9BA-04lNuu&3=reevNOMwmX(A{yh`^c8%0mjAKMj{Th05 zXrM(zILwyL-Pcdw^(=gj(ZLVMA95zlzmLa^skb8tQq%8SV&4vp?S>L3+P4^tp`$xA zr38jBw0ItR`VbO5vB1`<3d})}aorkIU1z3*ifYN&Lpp)}|}QJS60th_v-EEkAM zyOREuj!Ou|pVeZEWg;$Hf!x;xAmFu7gB^UR$=L0BuZ~thLC@#moJ(@@wejR|`t_K@ zuQ{XmpAWz%o&~2dk!SIGR$EmpZY)@+r^gvX26%)y>1u2bt~JUPTQzQu&_tB)|{19)&n$m5Fhw0A-8S1^%XpAD%`#a z_ModVxsM|x!m3N1vRt_XEL`O-+J3cMsM1l*dbjT&S0c@}Xxl3I&AeMNT97G3c6%3C zbrZS?2EAKcEq@@Pw?r%eh0YM6z0>&Qe#n+e9hEHK?fzig3v5S#O2IxVLu;a>~c~ZfHVbgLox%_tg)bsC8Rl35P=Jhl+Y=w6zb$ z;*uO%i^U z^mp_QggBILLF$AyjPD41Z0SFdbDj&z&xjq~X|OoM7bCuBfma1CEd!4RKGqPR)K)e}+7^JfFUI_fy63cMyq#&)Z*#w18{S zhC@f9U5k#2S2`d$-)cEoH-eAz{2Qh>YF1Xa)E$rWd52N-@{#lrw3lRqr)z?BGThgO z-Mn>X=RPHQ)#9h{3ciF)<>s{uf_&XdKb&kC!a373l2OCu&y8&n#P%$7YwAVJ_lD-G zX7tgMEV8}dY^mz`R6_0tQ5Eu@CdSOyaI63Vb*mR+rCzxgsjCXLSHOmzt0tA zGoA0Cp&l>rtO@^uQayrkoe#d2@}|?SlQl9W{fmcxY(0*y zHTZ6>FL;$8FEzbb;M(o%mBe-X?o<0+1dH?ZVjcf8)Kyqb07*a zLfP1blbt)=W)TN}4M#dUnt8Gdr4p$QRA<0W)JhWLK3-g82Q~2Drmx4J z;6m4re%igus136VL}MDI-V;WmSfs4guF_(7ifNl#M~Yx5HB!UF)>*-KDQl0U?u4UXV2I*qMhEfsxb%87fi+W;mW5{h?o8!52}VUs*Fpo#aSuXk(Ug z>r>xC#&2<9Uwmao@iJQ|{Vr__?eRT2NB$OcoXQ-jZ{t|?Uy{7q$nU-i|&-R6fHPWJDgHZ69iVbK#Ab@2@y zPD*Gj=hib?PWr8NGf;g$o5I!*n>94Z!IfqRm zLvM>Gx$Y*rEL3Z-+lS42=cnEfXR)h1z`h8a+I%E_ss%qXsrgIV%qv9d|KT>fV5=3e zw>P#ju>2naGc{=6!)9TeHq$S9Pk|>$UCEl}H}lE@;0(jbNT9TXUXyss>al>S4DuGi zVCy;Qt=a2`iu2;TvrIkh2NTvNV}0)qun~9y1yEQMdOf#V#3(e(C?+--8bCsJu={Q1z5qNJIk&yW>ZnVm;A=fL~29lvXQ*4j(SLau?P zi8LC7&**O!6B6=vfY%M;!p2L2tQ+w3Y!am{b?14E`h4kN$1L0XqT5=y=DW8GI_yi% zlIWsjmf0{l#|ei>)>&IM4>jXH)?>!fK?pfWIQn9gT9N(z&w3SvjlD|u*6T@oNQRF6 zU5Uo~SA}ml5f8mvxzX>BGL}c2#AT^6Lo-TM5XluWoqBRin$tiyRQK0wJ!Ro+7S!-K z=S95p-(#IDKOZsRd{l65N(Xae`wOa4Dg9?g|Jx97N-7OfHG(rN#k=yNGW0K$Tia5J zMMX1+!ulc1%8e*FNRV8jL|OSL-_9Nv6O=CH>Ty(W@sm`j=NFa1F3tT$?wM1}GZekB z6F_VLMCSd7(b9T%IqUMo$w9sM5wOA7l8xW<(1w0T=S}MB+9X5UT|+nemtm_;!|bxX z_bnOKN+F30ehJ$459k@=69yTz^_)-hNE4XMv$~_%vlH_y^`P1pLxYF6#_IZyteO`9wpuS> z#%Vyg5mMDt?}j!0}MoBX|9PS0#B zSVo6xLVjujMN57}IVc#A{VB*_yx;#mgM4~yT6wO;Qtm8MV6DX?u(JS~JFA~PvEl%9 z2XI}c>OzPoPn_IoyXa2v}BA(M+sWq=_~L0rZ_yR17I5c^m4;?2&KdCc)3lCs!M|0OzH@(PbG8T6w%N zKzR>%SLxL_C6~r3=xm9VG8<9yLHV6rJOjFHPaNdQHHflp><44l>&;)&7s)4lX%-er znWCv8eJJe1KAi_t1p%c4`bgxD2(1v)jm(gvQLp2K-=04oaIJu{F7SIu8&)gyw7x>+ zbzYF7KXg;T71w!-=C0DjcnF^JP$^o_N>*BAjtH!^HD6t1o?(O7IrmcodeQVDD<*+j zN)JdgB6v^iiJ1q`bZ(^WvN{v@sDqG$M9L`-UV!3q&sWZUnQ{&tAkpX(nZ_L#rMs}>p7l0fU5I5IzArncQi6TWjP#1B=QZ|Uqm-3{)YPn=XFqHW-~Fb z^!0CvIdelQbgcac9;By79%T`uvNhg9tS><pLzXePP=JZzcO@?5GRAdF4)sY*)YGP* zyioMa3=HRQz(v}+cqXc0%2*Q%CQi%e2~$a9r+X*u3J8w^Shg#%4I&?!$})y@ zzg8tQ6_-`|TBa_2v$D;Q(pFutj7@yos0W$&__9$|Yn3DFe*)k{g^|JIV4bqI@2%-4kpb_p? zQ4}qQcA>R6ihbxnVa{c;f7Y)VPV&mRY-*^qm~u3HB>8lf3P&&#GhQk8uIYYgwrugY zei>mp`YdC*R^Cxuv@d0V?$~d*=m-X?1Fqd9@*IM^wQ_^-nQEuc0!OqMr#TeT=8W`JbjjXc-Dh3NhnTj8e82yP;V_B<7LIejij+B{W1ViaJ_)+q?$BaLJpxt_4@&(?rWC3NC-_Z9Sg4JJWc( zX!Y34j67vCMHKB=JcJ1|#UI^D^mn(i=A5rf-iV7y4bR5HhC=I`rFPZv4F>q+h?l34 z4(?KYwZYHwkPG%kK7$A&M#=lpIn3Qo<>s6UFy|J$Zca-s(oM7??dkuKh?f5b2`m57 zJhs4BTcVVmwsswlX?#70uQb*k1Fi3q4+9`V+ikSk{L3K=-5HgN0JekQ=J~549Nd*+H%5+fi6aJuR=K zyD3xW{X$PL7&iR)=wumlTq2gY{LdrngAaPC;Qw_xLfVE0c0Z>y918TQpL!q@?`8{L!el18Qxiki3WZONF=eK$N3)p>36EW)I@Y z7QxbWW_9_7a*`VS&5~4-9!~&g8M+*U9{I2Bz`@TJ@E(YL$l+%<=?FyR#&e&v?Y@@G zqFF`J*v;l$&(A=s`na2>4ExKnxr`|OD+Xd-b4?6xl4mQ94xuk!-$l8*%+1zQU{)!= zTooUhjC0SNBh!&Ne}Q=1%`_r=Vu1c8RuE!|(g4BQGcd5AbpLbvKv_Z~Y`l!mr!sCc zDBupoc{W@U(6KWqW@xV_`;J0~+WDx|t^WeMri#=q0U5ZN7@@FAv<1!hP6!IYX z>UjbhaEv2Fk<6C0M^@J`lH#LgKJ(`?6z5=uH+ImggSQaZtvh52WTK+EBN~-op#EQKYW`$yBmq z4wgLTJPn3;mtbs0m0RO&+EG>?rb*ZECE0#eeSOFL!2YQ$w}cae>sun`<=}m!=go!v zO2jn<0tNh4E-4)ZA(ixh5nIUuXF-qYl>0I_1)K%EAw`D7~la$=gc@6g{iWF=>i_76?Mc zh#l9h7))<|EY=sK!E|54;c!b;Zp}HLd5*-w^6^whxB98v`*P>cj!Nfu1R%@bcp{cb zUZ24(fUXn3d&oc{6H%u(@4&_O?#HO(qd^YH=V`WJ=u*u6Zie8mE^r_Oz zDw`DaXeq4G#m@EK5+p40Xe!Lr!-jTQLCV3?R1|3#`%45h8#WSA!XoLDMS7=t!SluZ4H56;G z6C9D(B6>k^ur_DGfJ@Y-=3$5HkrI zO+3P>R@$6QZ#ATUI3$)xRBEL#5IKs}yhf&fK;ANA#Qj~G zdE|k|`puh$%dyE4R0$7dZd)M*#e7s%*PKPyrS;d%&S(d{_Ktq^!Hpi&bxZx`?9pEw z%sPjo&adHm95F7Z1{RdY#*a!&LcBZVRe{qhn8d{pOUJ{fOu`_kFg7ZVeRYZ(!ezNktT5{Ab z4BZI$vS0$vm3t9q`ECjDK;pmS{8ZTKs`Js~PYv2|=VkDv{Dtt)cLU@9%K6_KqtqfM zaE*e$f$Xm=;IAURNUXw8g%=?jzG2}10ZA5qXzAaJ@eh)yv5B=ETyVwC-a*CD;GgRJ z4J1~zMUey?4iVlS0zW|F-~0nenLiN3S0)l!T2}D%;<}Z9DzeVgcB+MSj;f$KY;uP%UR#f`0u*@6U@tk@jO3N?Fjq< z{cUUhjrr$rmo>qE?52zKe+>6iP5P_tcUfxsLSy{9*)shB(w`UUveNH`a`kr$VEF@} zKh&|lTD;4;m_H6C&)9#D`kRh;S(NTa=Ve^~xe_0~x$6h8Q@B_qu#ee=(lkI9@F6$0m=z@H=4&h%Q{htM>uHs(Sr@2ry`fgLA zKj8lVXdGPyy)2J%A${}Rm_a{){wHnlM?yGPQ7#KO{8*(_l0QZHuV};nO?c%h?qwSL z3wem|w*2tdxW5&PxC(Wd0QG_w|GPbw|0UFK`u$~U%!`QKcME;=Q@?*erh4_>FP~1n zAldwG9h$$u_$RFK6Uxo20GHqJzc}Rl-EwVz3h4n z;3~%DwD84i>)-8#&#y3k)3BG5cNaP3?t4q}F%yfv?*yEiC>sSo}$f>nh0QNZXH1N)-Q7kbk=2uL9OrF)nXrE@F1y%_8Yn c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 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.") +}