Initial commit: Hold'em engine, bots, and simulation harness
Kotlin Multiplatform engine (JVM target only for now; androidTarget and iosArm64 slot in without touching commonMain). Core: - HandEvaluator: single-pass 5-7 card evaluation, ~24M evals/sec. Verified exhaustively against published frequencies for all 2,598,960 five-card hands. - Equity: Monte Carlo with ties split. PreflopChart ranks the 169 starting hands using all-in equity plus an explicit playability adjustment, so looseness means "plays the top N%". - Table: no-limit betting rounds, side pots, odd-chip splits, uncalled-bet refunds, and incomplete (short all-in) raises that correctly do not reopen betting. Bots: - SkillLevel and PlayStyle are orthogonal axes. Skill drives decision quality (rollout accuracy, pot-odds discipline, position awareness, error rate); style drives bluffing, sandbagging, aggression, tightness. - BotMood gives tilt that persists between hands and decays. - OpponentModel lets Advanced/Expert exploit habitual bettors. - MathBot emits a DecisionTrace of the numbers behind each decision, which the coach will later hand to an LLM to narrate. The LLM never does poker maths. Simulator: - 2,200-3,400 hands/sec. Deck RNG is separate from bot RNGs so rollout counts cannot shift the deal. - Controlled skill-ladder test asserts the difficulty gradient is monotonic: 73.9 / 53.9 / 27.6 / -155.4 bb/100 over 50k hands. Assets: 52 CC0 English-pattern card faces plus generated backs. Tests: 30 passing (evaluator, table rules, pre-flop chart). Known open: win-rate magnitudes ~10x realistic and several profiles looser than their labels. Tuning, not correctness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user