diff --git a/CLAUDE.md b/CLAUDE.md index b485d6b..56878cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,12 +57,14 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" 6. **Pot odds are the post-flop baseline.** Never apply a blanket implied-odds discount: it is categorically wrong on the river, and future value on earlier streets must account for future costs and reverse implied odds before it is - called an advantage. + called an advantage. Price a call against `eligiblePot`, not the displayed + total: a short stack cannot win side-pot chips above its contribution level. 7. **DecisionTrace is the coach contract.** It records raw pot odds, the actual adjusted threshold, every adjustment, intended and chosen actions, and whether a skill error changed the decision. Pre-flop chart percentile/range fields are structurally separate from post-flop equity fields; inapplicable values are - null. The coach explains these values; it does not reconstruct hidden bot logic. + null. The production coach explains these values; it does not reconstruct + hidden bot logic. ## Testing notes @@ -118,6 +120,11 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" - A cash-game session begins at an explicit take-a-seat screen. Opponents may auto-reload below the big blind; the human is never silently topped up and must explicitly choose "Reload to N & deal" from the completed-hand screen. +- The deterministic coach is opt-in and post-action only. Its fundamentals + baseline receives `DecisionOffer`, so it can see the hero's cards and public + table state but no opponent hole cards. Post-flop equity is explicitly labelled + as an estimate against unknown random hands; disagreement is "worth reviewing," + never declared solver proof of a mistake. - Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build. -- Not built yet: LLM persona layer and opt-in coach. +- Not built yet: LLM persona layer. diff --git a/app/src/main/java/com/jsjdesigns/poker/CashGameSession.kt b/app/src/main/java/com/jsjdesigns/poker/CashGameSession.kt index c17f412..99a870c 100644 --- a/app/src/main/java/com/jsjdesigns/poker/CashGameSession.kt +++ b/app/src/main/java/com/jsjdesigns/poker/CashGameSession.kt @@ -7,6 +7,7 @@ internal const val DEFAULT_BIG_BLIND = 2 data class CashGameConfig( val playerName: String, + val coachEnabled: Boolean = false, val buyIn: Int = DEFAULT_BUY_IN, val smallBlind: Int = DEFAULT_SMALL_BLIND, val bigBlind: Int = DEFAULT_BIG_BLIND, @@ -18,13 +19,19 @@ data class CashGameConfig( * "You" remains the safe default, but a supplied name is trimmed, internal * whitespace is collapsed, and the table-sized label is bounded. */ -fun cashGameConfig(playerName: String): CashGameConfig { +fun cashGameConfig( + playerName: String, + coachEnabled: Boolean = false, +): CashGameConfig { val normalised = playerName .trim() .replace(Regex("\\s+"), " ") .take(MAX_PLAYER_NAME_LENGTH) .ifBlank { "You" } - return CashGameConfig(playerName = normalised) + return CashGameConfig( + playerName = normalised, + coachEnabled = coachEnabled, + ) } /** diff --git a/app/src/main/java/com/jsjdesigns/poker/CoachPresentation.kt b/app/src/main/java/com/jsjdesigns/poker/CoachPresentation.kt new file mode 100644 index 0000000..60ea4b4 --- /dev/null +++ b/app/src/main/java/com/jsjdesigns/poker/CoachPresentation.kt @@ -0,0 +1,39 @@ +package com.jsjdesigns.poker + +import com.jsjdesigns.poker.bot.CoachingReview +import com.jsjdesigns.poker.game.Action +import com.jsjdesigns.poker.game.ActionType +import kotlin.math.roundToInt + +data class CoachPresentation(val title: String, val detail: String) + +/** Renders only audited DecisionTrace fields; it never reconstructs poker math. */ +fun coachPresentation(review: CoachingReview): CoachPresentation { + val trace = review.trace + val title = if (review.alignedWithBaseline) "Baseline agrees" else "Worth reviewing" + val baseline = trace.intended.coachLabel() + + val handStrengthPercentile = trace.handStrengthPercentile + val detail = if (handStrengthPercentile != null) { + val percentile = (handStrengthPercentile * 100).roundToInt() + val range = (requireNotNull(trace.preflopRangeThreshold) * 100).roundToInt() + "Starting-hand percentile $percentile%; baseline plays $range% here. Baseline: $baseline." + } else { + val equity = (requireNotNull(trace.estimatedEquity) * 100).roundToInt() + if (trace.potOdds == "no bet to call") { + "Estimated $equity% equity vs random hands; no bet to call. Baseline: $baseline." + } else { + val needed = (requireNotNull(trace.breakEvenEquity) * 100).roundToInt() + "Estimated $equity% equity vs random hands; calling needs $needed%. Baseline: $baseline." + } + } + return CoachPresentation(title, detail) +} + +private fun Action.coachLabel(): String = when (type) { + ActionType.FOLD -> "Fold" + ActionType.CHECK -> "Check" + ActionType.CALL -> "Call $amount" + ActionType.BET -> "Bet $amount" + ActionType.RAISE -> "Raise to $amount" +} diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt b/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt index fdd9e82..4192af8 100644 --- a/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt +++ b/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt @@ -1,8 +1,10 @@ package com.jsjdesigns.poker import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -13,6 +15,8 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text @@ -43,8 +47,9 @@ fun PokerApp(vm: PokerViewModel) { } @Composable -private fun TakeASeatScreen(onStart: (String) -> Unit) { +private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) { var playerName by rememberSaveable { mutableStateOf("") } + var coachEnabled by rememberSaveable { mutableStateOf(false) } Column( modifier = Modifier @@ -90,6 +95,34 @@ private fun TakeASeatScreen(onStart: (String) -> Unit) { Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { coachEnabled = !coachEnabled } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = coachEnabled, + onCheckedChange = null, + colors = CheckboxDefaults.colors( + checkedColor = TableGold, + checkmarkColor = SetupFelt, + uncheckedColor = Color.White.copy(alpha = 0.55f), + ), + ) + Column { + Text("Post-action coach", color = Color.White, fontWeight = FontWeight.Medium) + Text( + "Reviews your choice after you act. No hints during decisions.", + color = Color.White.copy(alpha = 0.5f), + fontSize = 12.sp, + ) + } + } + + Spacer(Modifier.height(8.dp)) + Card( colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.08f)), modifier = Modifier.fillMaxWidth(), @@ -118,7 +151,7 @@ private fun TakeASeatScreen(onStart: (String) -> Unit) { Spacer(Modifier.height(24.dp)) Button( - onClick = { onStart(playerName) }, + onClick = { onStart(playerName, coachEnabled) }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = Color(0xFF2C6E49), diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt index d005cfe..0b6b647 100644 --- a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt +++ b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt @@ -3,6 +3,8 @@ package com.jsjdesigns.poker import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.jsjdesigns.poker.bot.BotProfile +import com.jsjdesigns.poker.bot.CoachingReview +import com.jsjdesigns.poker.bot.DecisionCoach import com.jsjdesigns.poker.bot.MathBot import com.jsjdesigns.poker.bot.PlayStyle import com.jsjdesigns.poker.bot.SkillLevel @@ -21,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlin.random.Random const val HERO_SEAT = 0 @@ -33,6 +36,9 @@ data class UiState( val handSummary: HandSummary? = null, val nextHandRequested: Boolean = false, val heroNeedsRebuy: Boolean = false, + val coachReview: CoachingReview? = null, + /** Most recent engine-owned human decision identity observed on screen. */ + val latestHeroDecisionToken: Long? = null, ) { /** * The decision to show, or null. @@ -62,6 +68,18 @@ data class UiState( snap.phase == TableSnapshot.Phase.COMPLETE return handSummary.takeIf { terminal && it?.handNumber == snap.handNumber } } + + /** + * An asynchronous review is accepted only while its exact decision remains + * the latest human decision in the displayed hand. + */ + fun acceptsCoachReview(review: CoachingReview): Boolean = + gameConfig?.coachEnabled == true && + latestHeroDecisionToken == review.decisionToken && + snapshot?.handNumber == review.handNumber + + fun visibleCoachReview(): CoachingReview? = + coachReview.takeIf { it != null && acceptsCoachReview(it) } } /** @@ -79,6 +97,7 @@ data class UiState( class PokerViewModel : ViewModel() { private val human = HumanAgent() + private val coach = DecisionCoach() private val nextHandGate = NextHandGate() // RENDEZVOUS, not a buffer. A 32-deep queue let the engine race dozens of // frames ahead of the animation, so the board on screen and the action being @@ -104,11 +123,11 @@ class PokerViewModel : ViewModel() { * Creates the table exactly once, after the player chooses to take a seat. * A rapid double tap cannot launch two engines against the same HumanAgent. */ - fun startGame(playerName: String) { + fun startGame(playerName: String, coachEnabled: Boolean) { if (gameStarted) return gameStarted = true - val config = cashGameConfig(playerName) + val config = cashGameConfig(playerName, coachEnabled) val roster = listOf( BotProfile(config.playerName, SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE), BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."), @@ -138,6 +157,11 @@ class PokerViewModel : ViewModel() { for (frame in frames) { _state.update { current -> val sameCompletedHand = current.handSummary?.handNumber == frame.handNumber + val heroDecisionToken = + frame.toActToken.takeIf { frame.toAct == HERO_SEAT } + val isNewHeroDecision = + heroDecisionToken != null && + heroDecisionToken != current.latestHeroDecisionToken current.copy( snapshot = frame.maskedFor(HERO_SEAT), // Keep the result visible after the tap until the new deal @@ -146,6 +170,11 @@ class PokerViewModel : ViewModel() { handSummary = current.handSummary.takeIf { sameCompletedHand }, nextHandRequested = current.nextHandRequested && sameCompletedHand, heroNeedsRebuy = current.heroNeedsRebuy && sameCompletedHand, + coachReview = current.coachReview.takeIf { + it?.handNumber == frame.handNumber && !isNewHeroDecision + }, + latestHeroDecisionToken = + heroDecisionToken ?: current.latestHeroDecisionToken, ) } delay(pacingMillis(frame)) @@ -225,7 +254,23 @@ class PokerViewModel : ViewModel() { * doubled tap is dropped rather than applied to whatever comes next. */ fun submit(token: Long, action: Action) { - viewModelScope.launch { human.submit(token, action) } + val decision = offer.value?.takeIf { it.token == token } + viewModelScope.launch { + val accepted = human.submit(token, action) + val config = _state.value.gameConfig + if (!accepted || decision == null || config?.coachEnabled != true) return@launch + + val review = withContext(Dispatchers.Default) { + coach.review(decision, action) + } + _state.update { current -> + if (current.acceptsCoachReview(review)) { + current.copy(coachReview = review) + } else { + current + } + } + } } fun fold(token: Long) = submit(token, Action(ActionType.FOLD)) diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index 673d0d3..add54d8 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -51,6 +51,7 @@ fun TableScreen(vm: PokerViewModel) { // Only show an action bar that belongs to the table currently on screen. val offer = state.liveOffer(rawOffer) val handSummary = state.visibleHandSummary() + val coachReview = state.visibleCoachReview() val status = snap?.let(::tableStatus) Column( @@ -147,6 +148,7 @@ fun TableScreen(vm: PokerViewModel) { nextHandRequested = state.nextHandRequested, rebuyRequired = state.heroNeedsRebuy, buyIn = state.gameConfig?.buyIn ?: DEFAULT_BUY_IN, + coachReview = coachReview, heroFolded = hero?.folded == true, onFold = vm::fold, onCheckCall = vm::checkOrCall, @@ -247,6 +249,7 @@ private fun ActionBar( nextHandRequested: Boolean, rebuyRequired: Boolean, buyIn: Int, + coachReview: com.jsjdesigns.poker.bot.CoachingReview?, heroFolded: Boolean, onFold: (Long) -> Unit, onCheckCall: (Long) -> Unit, @@ -262,6 +265,8 @@ private fun ActionBar( buyIn = buyIn, onNextHand = onNextHand, ) + } else if (coachReview != null) { + CoachBar(coachPresentation(coachReview)) } else { Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) { Text( @@ -335,6 +340,30 @@ private fun ActionBar( } } +@Composable +private fun CoachBar(presentation: CoachPresentation) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0xFF14357A).copy(alpha = 0.48f)), + ) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text( + "COACH • ${presentation.title}", + color = Color(0xFFE3C179), + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + ) + Text( + presentation.detail, + color = Color.White.copy(alpha = 0.78f), + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable private fun HandResultBar( summary: HandSummary, diff --git a/app/src/test/java/com/jsjdesigns/poker/CashGameSessionTest.kt b/app/src/test/java/com/jsjdesigns/poker/CashGameSessionTest.kt index 1f134da..8de269a 100644 --- a/app/src/test/java/com/jsjdesigns/poker/CashGameSessionTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/CashGameSessionTest.kt @@ -10,6 +10,12 @@ class CashGameSessionTest { @Test fun `blank player name has an honest second-person fallback`() { assertEquals("You", cashGameConfig(" ").playerName) + assertFalse("coach must be opt-in", cashGameConfig("Jay").coachEnabled) + } + + @Test + fun `coach preference crosses the session boundary explicitly`() { + assertTrue(cashGameConfig("Jay", coachEnabled = true).coachEnabled) } @Test diff --git a/app/src/test/java/com/jsjdesigns/poker/CoachPresentationTest.kt b/app/src/test/java/com/jsjdesigns/poker/CoachPresentationTest.kt new file mode 100644 index 0000000..d3dae9a --- /dev/null +++ b/app/src/test/java/com/jsjdesigns/poker/CoachPresentationTest.kt @@ -0,0 +1,115 @@ +package com.jsjdesigns.poker + +import com.jsjdesigns.poker.bot.CoachingReview +import com.jsjdesigns.poker.bot.DecisionTrace +import com.jsjdesigns.poker.game.Action +import com.jsjdesigns.poker.game.ActionType +import com.jsjdesigns.poker.game.Street +import com.jsjdesigns.poker.game.TableSnapshot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CoachPresentationTest { + + private fun review( + handNumber: Int = 4, + token: Long = 12, + street: Street = Street.RIVER, + aligned: Boolean = false, + trace: DecisionTrace = postflopTrace(), + ) = CoachingReview( + decisionToken = token, + handNumber = handNumber, + street = street, + trace = trace, + alignedWithBaseline = aligned, + ) + + @Test + fun `postflop presentation consumes audited equity and pot odds fields`() { + val presentation = coachPresentation(review()) + + assertEquals("Worth reviewing", presentation.title) + assertEquals( + "Estimated 34% equity vs random hands; calling needs 25%. Baseline: Call 20.", + presentation.detail, + ) + } + + @Test + fun `preflop presentation never describes percentile as equity`() { + val trace = DecisionTrace( + estimatedEquity = null, + handStrengthPercentile = 0.08, + breakEvenEquity = null, + decisionThreshold = null, + preflopRangeThreshold = 0.22, + potOdds = null, + intended = Action(ActionType.RAISE, 6), + chosen = Action(ActionType.RAISE, 8), + mistakeApplied = false, + adjustments = emptyList(), + reason = "chart baseline", + ) + + val presentation = coachPresentation( + review(street = Street.PREFLOP, aligned = true, trace = trace), + ) + + assertEquals("Baseline agrees", presentation.title) + assertTrue("percentile" in presentation.detail) + assertTrue("equity" !in presentation.detail) + assertTrue("Raise to 6" in presentation.detail) + } + + @Test + fun `review is visible only for opted-in exact latest decision and hand`() { + val review = review() + val matching = UiState( + gameConfig = CashGameConfig("Jay", coachEnabled = true), + snapshot = snapshot(handNumber = 4), + coachReview = review, + latestHeroDecisionToken = 12, + ) + + assertEquals(review, matching.visibleCoachReview()) + assertNull( + matching.copy( + gameConfig = matching.gameConfig?.copy(coachEnabled = false), + ).visibleCoachReview(), + ) + assertNull(matching.copy(latestHeroDecisionToken = 13).visibleCoachReview()) + assertNull(matching.copy(snapshot = snapshot(handNumber = 5)).visibleCoachReview()) + } + + private fun postflopTrace() = DecisionTrace( + estimatedEquity = 0.34, + handStrengthPercentile = null, + breakEvenEquity = 0.25, + decisionThreshold = 0.25, + preflopRangeThreshold = null, + potOdds = "60:20", + intended = Action(ActionType.CALL, 20), + chosen = Action(ActionType.FOLD), + mistakeApplied = false, + adjustments = emptyList(), + reason = "fundamentals baseline", + ) + + private fun snapshot(handNumber: Int) = TableSnapshot( + handNumber = handNumber, + street = Street.RIVER, + phase = TableSnapshot.Phase.BETTING, + board = emptyList(), + pot = 80, + currentBet = 20, + minRaiseSize = 20, + button = 0, + seats = emptyList(), + toAct = null, + toActToken = null, + lastAction = null, + ) +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/DecisionCoach.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/DecisionCoach.kt new file mode 100644 index 0000000..3f3fb0d --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/DecisionCoach.kt @@ -0,0 +1,194 @@ +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.DecisionOffer +import com.jsjdesigns.poker.game.Street +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * Offline review of one accepted human decision. + * + * This is a transparent fundamentals baseline, not a solver and not a disguised + * bot persona. It receives only [DecisionOffer] — the immutable information the + * human was allowed to know — so opponent hole cards cannot leak into advice. + */ +data class CoachingReview( + val decisionToken: Long, + val handNumber: Int, + val street: Street, + val trace: DecisionTrace, + /** Same strategic action family; bet sizing differences are not condemned. */ + val alignedWithBaseline: Boolean, +) + +class DecisionCoach( + private val equityIterations: Int = 3_000, +) { + init { + require(equityIterations > 0) { "equity iterations must be positive" } + } + + fun review(offer: DecisionOffer, chosen: Action): CoachingReview { + val trace = if (offer.street == Street.PREFLOP) { + reviewPreflop(offer, chosen) + } else { + reviewPostflop(offer, chosen) + } + return CoachingReview( + decisionToken = offer.token, + handNumber = offer.handNumber, + street = offer.street, + trace = trace, + alignedWithBaseline = sameActionFamily(trace.intended, chosen), + ) + } + + private fun reviewPreflop(offer: DecisionOffer, chosen: Action): DecisionTrace { + val percentile = PreflopChart.percentile(offer.hole.toIntArray()) + val positionFactor = balancedPositionFactor( + activeOpponents = offer.activeOpponents, + inPosition = offer.inPosition, + awareness = 1.0, + inPositionDelta = 0.45, + ) + // What this seat still owes is not enough to identify a raise: the big + // blind can face a min-raise while owing only one big blind. + val facingRaise = offer.currentBet > offer.bigBlind + val facingRaiseFactor = if (facingRaise) 0.45 else 1.0 + val range = (BASELINE_PREFLOP_RANGE * positionFactor * facingRaiseFactor) + .coerceIn(0.01, 1.0) + val raiseRange = range * BASELINE_RAISE_SHARE + + val intended = when { + percentile <= raiseRange && offer.canRaise -> + aggressiveAction(offer, preflop = true, facingRaise = facingRaise) + percentile <= range -> + if (offer.canCheck) Action(ActionType.CHECK) + else Action(ActionType.CALL, offer.callAmount) + else -> + if (offer.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD) + } + + return DecisionTrace( + estimatedEquity = null, + handStrengthPercentile = percentile, + breakEvenEquity = null, + decisionThreshold = null, + preflopRangeThreshold = range, + potOdds = null, + intended = intended, + chosen = chosen, + // A human deviation is not a bot skill-error transform. + mistakeApplied = false, + adjustments = listOf( + ThresholdAdjustment("position", positionFactor), + ThresholdAdjustment("facing raise", facingRaiseFactor), + ), + reason = buildString { + append("Starting hand is in the top ${(percentile * 100).roundToInt()}%; ") + append("the fundamentals baseline range here is ${(range * 100).roundToInt()}%.") + }, + ) + } + + private fun reviewPostflop(offer: DecisionOffer, chosen: Action): DecisionTrace { + val equity = Equity.estimate( + hole = offer.hole.toIntArray(), + board = offer.board.toIntArray(), + opponents = offer.activeOpponents.coerceAtLeast(1), + iterations = equityIterations, + random = Random(seedFor(offer)), + ) + val breakEven = Equity.potOdds(offer.eligiblePot, offer.callAmount) + val intended = when { + offer.canCheck && equity >= VALUE_BET_EQUITY && offer.canRaise -> + aggressiveAction(offer, preflop = false, facingRaise = false) + offer.canCheck -> + Action(ActionType.CHECK) + equity < breakEven -> + Action(ActionType.FOLD) + equity >= VALUE_RAISE_EQUITY && offer.canRaise -> + aggressiveAction(offer, preflop = false, facingRaise = true) + else -> + Action(ActionType.CALL, offer.callAmount) + } + + return DecisionTrace( + estimatedEquity = equity, + handStrengthPercentile = null, + breakEvenEquity = breakEven, + decisionThreshold = breakEven, + preflopRangeThreshold = null, + potOdds = if (offer.toCall > 0) { + "${offer.eligiblePot}:${offer.callAmount}" + } else { + "no bet to call" + }, + intended = intended, + chosen = chosen, + mistakeApplied = false, + adjustments = emptyList(), + reason = buildString { + append("About ${(equity * 100).roundToInt()}% equity against ") + append("${offer.activeOpponents} unknown random hand(s)") + if (offer.toCall > 0) { + append("; raw pot odds require ${(breakEven * 100).roundToInt()}%.") + } else { + append(", with no bet to call.") + } + }, + ) + } + + private fun aggressiveAction( + offer: DecisionOffer, + preflop: Boolean, + facingRaise: Boolean, + ): Action { + // A big blind may check pre-flop while still raising an existing blind. + // Post-flop, a free aggressive action opens the betting. + val type = if (preflop || !offer.canCheck) ActionType.RAISE else ActionType.BET + if (offer.maxRaiseTo <= offer.minRaiseTo) { + return Action(type, offer.maxRaiseTo) + } + val desired = when { + preflop && !facingRaise -> offer.bigBlind * 3 + preflop -> offer.minRaiseTo + (offer.pot * 0.40).roundToInt() + else -> offer.minRaiseTo + (offer.pot * 0.50).roundToInt() + } + return Action(type, desired.coerceIn(offer.minRaiseTo, offer.maxRaiseTo)) + } + + private fun seedFor(offer: DecisionOffer): Int { + var seed = 17 + seed = seed * 31 + offer.handNumber + seed = seed * 31 + offer.street.ordinal + seed = seed * 31 + offer.activeOpponents + for (card in offer.hole) seed = seed * 31 + card + for (card in offer.board) seed = seed * 31 + card + return seed + } + + private fun sameActionFamily(recommended: Action, chosen: Action): Boolean { + val recommendedAggressive = + recommended.type == ActionType.BET || recommended.type == ActionType.RAISE + val chosenAggressive = + chosen.type == ActionType.BET || chosen.type == ActionType.RAISE + return if (recommendedAggressive || chosenAggressive) { + recommendedAggressive && chosenAggressive + } else { + recommended.type == chosen.type + } + } + + private companion object { + const val BASELINE_PREFLOP_RANGE = 0.22 + const val BASELINE_RAISE_SHARE = 0.59 + const val VALUE_BET_EQUITY = 0.62 + const val VALUE_RAISE_EQUITY = 0.68 + } +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt index b271cb4..97b606b 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt @@ -13,8 +13,9 @@ 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. + * Kept deliberately explicit because the deterministic coach UI consumes this + * contract directly, and a later LLM may narrate it. Neither consumer computes + * or reconstructs poker maths. */ data class DecisionTrace( /** Monte Carlo equity when equity is actually estimated; null pre-flop. */ @@ -30,7 +31,9 @@ data class DecisionTrace( val potOdds: String?, /** What the strategy selected before a skill error was applied. */ val intended: Action, + /** Action actually taken by the bot or human whose decision is reviewed. */ val chosen: Action, + /** True only when bot skill-error injection changed [intended]. */ val mistakeApplied: Boolean, /** Multipliers applied to the raw threshold, in evaluation order. */ val adjustments: List, @@ -99,7 +102,10 @@ class MathBot( 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) + // A short stack cannot win side-pot chips above its final contribution. + // Price the affordable call against only the pot this seat can contest. + val affordableCall = minOf(ctx.toCall, ctx.stack) + val breakEven = Equity.potOdds(ctx.eligiblePot, affordableCall) // Pot odds are the honest baseline. In particular, the river has no // future street from which to earn "implied" chips, so a blanket discount @@ -161,7 +167,7 @@ class MathBot( breakEvenEquity = breakEven, decisionThreshold = bar, preflopRangeThreshold = null, - potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call", + potOdds = if (ctx.toCall > 0) "${ctx.eligiblePot}:$affordableCall" else "no bet to call", intended = intended, chosen = chosen, mistakeApplied = mistakeApplied, @@ -204,7 +210,7 @@ class MathBot( pct <= raiseGate && ctx.canRaise -> Action(ActionType.RAISE, preflopRaiseTo(ctx, facingRaise)) pct <= gate -> - if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall) + if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount) else -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD) } @@ -259,7 +265,7 @@ class MathBot( // 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) + return if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount) } if (ctx.canCheck) { @@ -301,7 +307,7 @@ class MathBot( return Action(ActionType.RAISE, sizeBet(ctx, equity, aggression)) } - return Action(ActionType.CALL, ctx.toCall) + return Action(ActionType.CALL, ctx.callAmount) } /** Pot-fraction sizing, widening with equity and aggression. */ @@ -334,7 +340,7 @@ class MathBot( ): Action = when (intended.type) { ActionType.FOLD -> if (ctx.toCall > 0 && percentile <= gate + PREFLOP_ERROR_BAND) { - Action(ActionType.CALL, ctx.toCall) + Action(ActionType.CALL, ctx.callAmount) } else { intended } @@ -345,7 +351,7 @@ class MathBot( intended } ActionType.BET, ActionType.RAISE -> - if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall) + if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount) ActionType.CHECK -> intended } @@ -360,14 +366,14 @@ class MathBot( rawBreakEven: Double, ): Action = when (intended.type) { ActionType.FOLD -> - if (ctx.toCall > 0) Action(ActionType.CALL, ctx.toCall) else intended + if (ctx.toCall > 0) Action(ActionType.CALL, ctx.callAmount) else intended ActionType.CALL -> when { equity >= rawBreakEven -> Action(ActionType.FOLD) ctx.canRaise -> Action(ActionType.RAISE, ctx.minRaiseTo) else -> intended } ActionType.BET, ActionType.RAISE -> - if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall) + if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount) ActionType.CHECK -> if (equity < 0.25 && ctx.canRaise) Action(ActionType.BET, ctx.minRaiseTo) else intended } diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt index 71841bc..e30cfaf 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt @@ -29,6 +29,14 @@ data class DecisionOffer( val stack: Int, val canCheck: Boolean, val canRaise: Boolean, + /** Public table state needed by deterministic post-action analysis. */ + val activeOpponents: Int = 1, + val seatsActingAfter: Int = 0, + val bigBlind: Int = 2, + /** Current pot this seat can contest if it calls; excludes the call and higher side pots. */ + val eligiblePot: Int = pot, + /** Highest amount committed by any seat in the current betting round. */ + val currentBet: Int = if (street == Street.PREFLOP) bigBlind else 0, ) { /** * What calling actually costs. @@ -42,6 +50,8 @@ data class DecisionOffer( /** True when calling would put this player all in. */ val callIsAllIn: Boolean get() = toCall >= stack + + val inPosition: Boolean get() = seatsActingAfter == 0 } fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer( @@ -58,4 +68,9 @@ fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer( stack = stack, canCheck = canCheck, canRaise = canRaise, + activeOpponents = activeOpponents, + seatsActingAfter = seatsActingAfter, + bigBlind = bigBlind, + eligiblePot = eligiblePot, + currentBet = currentBet, ) diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt index 8cf46de..fec9a77 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -77,10 +77,20 @@ class DecisionContext( val decisionToken: Long, /** TDA Rule 47: is this player facing at least a full raise since acting? */ val bettingReopened: Boolean, + /** + * Portion of the current [pot] this seat can contest if it pays its affordable + * call. The call itself is not included. Excludes unmatched excess and side + * pots above this seat's final level. + */ + val eligiblePot: Int = pot, + /** Highest amount committed by any seat in the current betting round. */ + val currentBet: Int = if (street == Street.PREFLOP) bigBlind else 0, ) { val hole: IntArray get() = seat.hole val stack: Int get() = seat.stack val canCheck: Boolean get() = toCall == 0 + val callAmount: Int get() = minOf(toCall, stack) + val callIsAllIn: Boolean get() = toCall >= stack /** * True when this player may still put in a raise. @@ -444,6 +454,9 @@ class Table( private fun buildContext(street: Street, seat: Seat, toCall: Int): DecisionContext { val minRaiseTo = currentBet + minRaiseSize val maxRaiseTo = seat.committedThisRound + seat.stack + val affordableCall = minOf(toCall, seat.stack) + val finalContribution = seat.committedThisHand + affordableCall + val eligiblePot = seats.sumOf { minOf(it.committedThisHand, finalContribution) } var after = 0 var i = (seat.index + 1) % seats.size @@ -468,6 +481,8 @@ class Table( handNumber = handNumber, decisionToken = pendingToken, bettingReopened = mayRaise(seat), + eligiblePot = eligiblePot, + currentBet = currentBet, ) } diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionCoachTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionCoachTest.kt new file mode 100644 index 0000000..cb721f6 --- /dev/null +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionCoachTest.kt @@ -0,0 +1,162 @@ +package com.jsjdesigns.poker.bot + +import com.jsjdesigns.poker.core.cardsOf +import com.jsjdesigns.poker.game.Action +import com.jsjdesigns.poker.game.ActionType +import com.jsjdesigns.poker.game.DecisionOffer +import com.jsjdesigns.poker.game.Street +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DecisionCoachTest { + + private fun offer( + street: Street = Street.RIVER, + hole: String = "Ah Qh", + board: String = "2c 7d 9s Jc 3h", + pot: Int = 80, + toCall: Int = 20, + canCheck: Boolean = false, + canRaise: Boolean = true, + activeOpponents: Int = 1, + stack: Int = 200, + eligiblePot: Int = pot, + currentBet: Int = if (street == Street.PREFLOP) 2 else 0, + ) = DecisionOffer( + token = 91, + handNumber = 7, + street = street, + seat = 0, + hole = cardsOf(hole).toList(), + board = cardsOf(board).toList(), + pot = pot, + toCall = toCall, + minRaiseTo = 60, + maxRaiseTo = 200, + stack = stack, + canCheck = canCheck, + canRaise = canRaise, + activeOpponents = activeOpponents, + seatsActingAfter = 1, + bigBlind = 2, + eligiblePot = eligiblePot, + currentBet = currentBet, + ) + + @Test + fun `same public decision always produces the same review`() { + val coach = DecisionCoach(equityIterations = 600) + val decision = offer() + val chosen = Action(ActionType.FOLD) + + assertEquals(coach.review(decision, chosen), coach.review(decision, chosen)) + } + + @Test + fun `postflop trace is honest about equity and raw pot odds`() { + val review = DecisionCoach(equityIterations = 800).review( + offer(pot = 80, toCall = 20), + Action(ActionType.CALL, 20), + ) + val trace = review.trace + + assertNotNull(trace.estimatedEquity) + assertEquals(0.20, trace.breakEvenEquity!!, 1e-12) + assertEquals(trace.breakEvenEquity, trace.decisionThreshold) + assertEquals("80:20", trace.potOdds) + assertNull(trace.handStrengthPercentile) + assertTrue("unknown random hand" in trace.reason) + assertFalse(trace.mistakeApplied, "human disagreement is not a bot skill error") + } + + @Test + fun `preflop review uses chart fields and never fabricates equity`() { + val review = DecisionCoach().review( + offer(street = Street.PREFLOP, hole = "As Ad", board = ""), + Action(ActionType.RAISE, 6), + ) + val trace = review.trace + + assertNull(trace.estimatedEquity) + assertNotNull(trace.handStrengthPercentile) + assertNotNull(trace.preflopRangeThreshold) + assertNull(trace.breakEvenEquity) + assertNull(trace.decisionThreshold) + assertNull(trace.potOdds) + } + + @Test + fun `big blind option is described as a raise rather than a bet`() { + val review = DecisionCoach().review( + offer( + street = Street.PREFLOP, + hole = "As Ad", + board = "", + toCall = 0, + canCheck = true, + ), + Action(ActionType.RAISE, 60), + ) + + assertEquals(ActionType.RAISE, review.trace.intended.type) + assertTrue(review.alignedWithBaseline) + } + + @Test + fun `preflop raise is identified by table bet level rather than amount owed`() { + val coach = DecisionCoach() + val unopened = coach.review( + offer(street = Street.PREFLOP, board = "", toCall = 2, currentBet = 2), + Action(ActionType.FOLD), + ) + val facingMinimumRaise = coach.review( + // The big blind has 2 invested, so a raise to 4 leaves only 2 owed. + offer(street = Street.PREFLOP, board = "", toCall = 2, currentBet = 4), + Action(ActionType.FOLD), + ) + + assertEquals( + unopened.trace.preflopRangeThreshold!! * 0.45, + facingMinimumRaise.trace.preflopRangeThreshold!!, + 1e-12, + ) + } + + @Test + fun `bet and raise spellings are the same aggressive action family`() { + val review = DecisionCoach(equityIterations = 300).review( + offer( + hole = "As Ad", + board = "Ah 7d 9s Jc 3h", + toCall = 0, + canCheck = true, + ), + // The app sends RAISE; Table sanitises it to BET when no bet exists. + Action(ActionType.RAISE, 60), + ) + + assertEquals(ActionType.BET, review.trace.intended.type) + assertTrue(review.alignedWithBaseline) + } + + @Test + fun `short all-in odds exclude chips in side pots the hero cannot win`() { + val review = DecisionCoach(equityIterations = 300).review( + offer( + pot = 120, + eligiblePot = 40, + toCall = 100, + stack = 20, + canRaise = false, + ), + Action(ActionType.CALL, 20), + ) + + assertEquals(1.0 / 3.0, review.trace.breakEvenEquity!!, 1e-12) + assertEquals("40:20", review.trace.potOdds) + } +} diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt index 871e92d..baad065 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/DecisionTraceTest.kt @@ -120,4 +120,38 @@ class DecisionTraceTest { } } } + + @Test + fun `bot pot odds exclude inaccessible side-pot chips`() = runTest { + val bot = MathBot( + BotProfile("E", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE), + Random(19), + ) + val seat = Seat(0, "Expert", 20, bot) + seat.hole = cardsOf("As Ad") + val ctx = DecisionContext( + street = Street.RIVER, + seat = seat, + board = cardsOf("Ah 7d 9s Jc 3h"), + pot = 120, + toCall = 100, + minRaiseTo = 220, + maxRaiseTo = 20, + activeOpponents = 2, + seatsActingAfter = 1, + bigBlind = 2, + history = emptyList(), + handNumber = 1, + decisionToken = 1, + bettingReopened = false, + eligiblePot = 40, + ) + + bot.act(ctx) + + assertEquals(1.0 / 3.0, bot.lastTrace!!.breakEvenEquity!!, 1e-12) + assertEquals("40:20", bot.lastTrace!!.potOdds) + assertEquals(com.jsjdesigns.poker.game.ActionType.CALL, bot.lastTrace!!.intended.type) + assertEquals(20, bot.lastTrace!!.intended.amount) + } } diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt index f813df1..117102d 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt @@ -15,6 +15,7 @@ private data class Offer( val canRaise: Boolean, val canCheck: Boolean, val pot: Int, + val eligiblePot: Int, val minRaiseTo: Int, val maxRaiseTo: Int, ) @@ -26,7 +27,7 @@ private class Scripted(private vararg val actions: Action) : PlayerAgent { override suspend fun act(ctx: DecisionContext): Action { offers += Offer( ctx.street, ctx.toCall, ctx.canRaise, ctx.canCheck, - ctx.pot, ctx.minRaiseTo, ctx.maxRaiseTo, + ctx.pot, ctx.eligiblePot, ctx.minRaiseTo, ctx.maxRaiseTo, ) return actions.getOrElse(i++) { if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD) @@ -183,6 +184,32 @@ class TableRulesTest { assertTrue(result.net[2] < 0, "queens should lose") } + @Test + fun `decision context excludes inaccessible side-pot chips from pot odds`() = runTest { + val short = Scripted(Action(ActionType.CALL, 20), Action(ActionType.FOLD)) + val raiser = Scripted(Action(ActionType.RAISE, 100)) + val caller = Scripted(Action(ActionType.CALL, 80)) + val seats = listOf( + Seat(0, "Short", 50, short), + Seat(1, "Raiser", 200, raiser), + Seat(2, "Caller", 200, caller), + ) + + Table( + seats, 10, 20, Random(1), + StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"), + ).playHand() + + val facingRaise = short.offers[1] + assertEquals(220, facingRaise.pot, "the visible table pot includes the higher side pot") + assertEquals( + 120, + facingRaise.eligiblePot, + "short stack may contest only 50 from each opponent plus its 20 already committed", + ) + assertEquals(80, facingRaise.toCall) + } + /** * A genuinely ODD pot: the folded small blind's 5 chips make 25, which two * winners cannot split evenly. TDA Rule 20 sends the odd chip to the first