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:
@@ -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.")
|
||||
}
|
||||
Reference in New Issue
Block a user