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) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-25 04:36:03 -04:00
commit 479be1f6b9
82 changed files with 54638 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
plugins {
kotlin("jvm")
application
}
dependencies {
implementation(project(":engine"))
}
application {
mainClass.set("com.jsjdesigns.poker.sim.MainKt")
}
@@ -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<BotProfile>, hands: Int, seed: Long): List<Stats> {
// 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<Pair<String, Double>>()
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<String>) {
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.")
}