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
+15
View File
@@ -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"))
}
}
}
@@ -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
@@ -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<Int, Int>()
private val totalActions = HashMap<Int, Int>()
private var consumed = 0
/** Folds in any events not yet seen. History resets each hand, so detect that. */
fun observe(history: List<HandEvent>) {
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
}
}
@@ -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
}
@@ -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() }
@@ -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<String>, board: String = "", filler: String = ""): StackedDeck {
val cards = ArrayList<Int>()
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())
}
}
}
@@ -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() }
}
@@ -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)
}
@@ -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
}
}
@@ -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<Pair<Int, Double>>(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
}
}
@@ -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<HandEvent>,
) {
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<Int>)
data class HandResult(
val board: IntArray,
/** Net chip change per seat for this hand. */
val net: IntArray,
val winners: List<Int>,
val wentToShowdown: Boolean,
val potSize: Int,
val events: List<HandEvent>,
)
/**
* 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<Seat>,
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<Int>(5)
private val events = ArrayList<HandEvent>()
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<Int>()
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<Int, Int>()
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<Pot>()
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() }
}
@@ -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",
)
}
}
}
@@ -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<Double>()
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")
}
}
@@ -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<Offer>()
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)
}
}