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
@@ -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() }
}