Add deterministic post-action coach

This commit is contained in:
Jay
2026-07-26 15:27:25 -04:00
parent 3aa55dd2ce
commit 4d8a62afb6
15 changed files with 756 additions and 22 deletions
+10 -3
View File
@@ -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 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 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 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 7. **DecisionTrace is the coach contract.** It records raw pot odds, the actual
adjusted threshold, every adjustment, intended and chosen actions, and whether adjusted threshold, every adjustment, intended and chosen actions, and whether
a skill error changed the decision. Pre-flop chart percentile/range fields are a skill error changed the decision. Pre-flop chart percentile/range fields are
structurally separate from post-flop equity fields; inapplicable values 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 ## 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 - 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 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. 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 - Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's
own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build. 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.
@@ -7,6 +7,7 @@ internal const val DEFAULT_BIG_BLIND = 2
data class CashGameConfig( data class CashGameConfig(
val playerName: String, val playerName: String,
val coachEnabled: Boolean = false,
val buyIn: Int = DEFAULT_BUY_IN, val buyIn: Int = DEFAULT_BUY_IN,
val smallBlind: Int = DEFAULT_SMALL_BLIND, val smallBlind: Int = DEFAULT_SMALL_BLIND,
val bigBlind: Int = DEFAULT_BIG_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 * "You" remains the safe default, but a supplied name is trimmed, internal
* whitespace is collapsed, and the table-sized label is bounded. * 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 val normalised = playerName
.trim() .trim()
.replace(Regex("\\s+"), " ") .replace(Regex("\\s+"), " ")
.take(MAX_PLAYER_NAME_LENGTH) .take(MAX_PLAYER_NAME_LENGTH)
.ifBlank { "You" } .ifBlank { "You" }
return CashGameConfig(playerName = normalised) return CashGameConfig(
playerName = normalised,
coachEnabled = coachEnabled,
)
} }
/** /**
@@ -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"
}
@@ -1,8 +1,10 @@
package com.jsjdesigns.poker package com.jsjdesigns.poker
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,6 +15,8 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -43,8 +47,9 @@ fun PokerApp(vm: PokerViewModel) {
} }
@Composable @Composable
private fun TakeASeatScreen(onStart: (String) -> Unit) { private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) {
var playerName by rememberSaveable { mutableStateOf("") } var playerName by rememberSaveable { mutableStateOf("") }
var coachEnabled by rememberSaveable { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = Modifier
@@ -90,6 +95,34 @@ private fun TakeASeatScreen(onStart: (String) -> Unit) {
Spacer(Modifier.height(16.dp)) 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( Card(
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.08f)), colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.08f)),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -118,7 +151,7 @@ private fun TakeASeatScreen(onStart: (String) -> Unit) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Button( Button(
onClick = { onStart(playerName) }, onClick = { onStart(playerName, coachEnabled) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2C6E49), containerColor = Color(0xFF2C6E49),
@@ -3,6 +3,8 @@ package com.jsjdesigns.poker
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.jsjdesigns.poker.bot.BotProfile 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.MathBot
import com.jsjdesigns.poker.bot.PlayStyle import com.jsjdesigns.poker.bot.PlayStyle
import com.jsjdesigns.poker.bot.SkillLevel import com.jsjdesigns.poker.bot.SkillLevel
@@ -21,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random import kotlin.random.Random
const val HERO_SEAT = 0 const val HERO_SEAT = 0
@@ -33,6 +36,9 @@ data class UiState(
val handSummary: HandSummary? = null, val handSummary: HandSummary? = null,
val nextHandRequested: Boolean = false, val nextHandRequested: Boolean = false,
val heroNeedsRebuy: 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. * The decision to show, or null.
@@ -62,6 +68,18 @@ data class UiState(
snap.phase == TableSnapshot.Phase.COMPLETE snap.phase == TableSnapshot.Phase.COMPLETE
return handSummary.takeIf { terminal && it?.handNumber == snap.handNumber } 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() { class PokerViewModel : ViewModel() {
private val human = HumanAgent() private val human = HumanAgent()
private val coach = DecisionCoach()
private val nextHandGate = NextHandGate() private val nextHandGate = NextHandGate()
// RENDEZVOUS, not a buffer. A 32-deep queue let the engine race dozens of // 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 // 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. * 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. * 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 if (gameStarted) return
gameStarted = true gameStarted = true
val config = cashGameConfig(playerName) val config = cashGameConfig(playerName, coachEnabled)
val roster = listOf( val roster = listOf(
BotProfile(config.playerName, SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE), BotProfile(config.playerName, SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE),
BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."), BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."),
@@ -138,6 +157,11 @@ class PokerViewModel : ViewModel() {
for (frame in frames) { for (frame in frames) {
_state.update { current -> _state.update { current ->
val sameCompletedHand = current.handSummary?.handNumber == frame.handNumber 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( current.copy(
snapshot = frame.maskedFor(HERO_SEAT), snapshot = frame.maskedFor(HERO_SEAT),
// Keep the result visible after the tap until the new deal // Keep the result visible after the tap until the new deal
@@ -146,6 +170,11 @@ class PokerViewModel : ViewModel() {
handSummary = current.handSummary.takeIf { sameCompletedHand }, handSummary = current.handSummary.takeIf { sameCompletedHand },
nextHandRequested = current.nextHandRequested && sameCompletedHand, nextHandRequested = current.nextHandRequested && sameCompletedHand,
heroNeedsRebuy = current.heroNeedsRebuy && sameCompletedHand, heroNeedsRebuy = current.heroNeedsRebuy && sameCompletedHand,
coachReview = current.coachReview.takeIf {
it?.handNumber == frame.handNumber && !isNewHeroDecision
},
latestHeroDecisionToken =
heroDecisionToken ?: current.latestHeroDecisionToken,
) )
} }
delay(pacingMillis(frame)) delay(pacingMillis(frame))
@@ -225,7 +254,23 @@ class PokerViewModel : ViewModel() {
* doubled tap is dropped rather than applied to whatever comes next. * doubled tap is dropped rather than applied to whatever comes next.
*/ */
fun submit(token: Long, action: Action) { 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)) fun fold(token: Long) = submit(token, Action(ActionType.FOLD))
@@ -51,6 +51,7 @@ fun TableScreen(vm: PokerViewModel) {
// Only show an action bar that belongs to the table currently on screen. // Only show an action bar that belongs to the table currently on screen.
val offer = state.liveOffer(rawOffer) val offer = state.liveOffer(rawOffer)
val handSummary = state.visibleHandSummary() val handSummary = state.visibleHandSummary()
val coachReview = state.visibleCoachReview()
val status = snap?.let(::tableStatus) val status = snap?.let(::tableStatus)
Column( Column(
@@ -147,6 +148,7 @@ fun TableScreen(vm: PokerViewModel) {
nextHandRequested = state.nextHandRequested, nextHandRequested = state.nextHandRequested,
rebuyRequired = state.heroNeedsRebuy, rebuyRequired = state.heroNeedsRebuy,
buyIn = state.gameConfig?.buyIn ?: DEFAULT_BUY_IN, buyIn = state.gameConfig?.buyIn ?: DEFAULT_BUY_IN,
coachReview = coachReview,
heroFolded = hero?.folded == true, heroFolded = hero?.folded == true,
onFold = vm::fold, onFold = vm::fold,
onCheckCall = vm::checkOrCall, onCheckCall = vm::checkOrCall,
@@ -247,6 +249,7 @@ private fun ActionBar(
nextHandRequested: Boolean, nextHandRequested: Boolean,
rebuyRequired: Boolean, rebuyRequired: Boolean,
buyIn: Int, buyIn: Int,
coachReview: com.jsjdesigns.poker.bot.CoachingReview?,
heroFolded: Boolean, heroFolded: Boolean,
onFold: (Long) -> Unit, onFold: (Long) -> Unit,
onCheckCall: (Long) -> Unit, onCheckCall: (Long) -> Unit,
@@ -262,6 +265,8 @@ private fun ActionBar(
buyIn = buyIn, buyIn = buyIn,
onNextHand = onNextHand, onNextHand = onNextHand,
) )
} else if (coachReview != null) {
CoachBar(coachPresentation(coachReview))
} else { } else {
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
Text( 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 @Composable
private fun HandResultBar( private fun HandResultBar(
summary: HandSummary, summary: HandSummary,
@@ -10,6 +10,12 @@ class CashGameSessionTest {
@Test @Test
fun `blank player name has an honest second-person fallback`() { fun `blank player name has an honest second-person fallback`() {
assertEquals("You", cashGameConfig(" ").playerName) 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 @Test
@@ -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,
)
}
@@ -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
}
}
@@ -13,8 +13,9 @@ import kotlin.random.Random
/** /**
* The numbers behind one decision. * The numbers behind one decision.
* *
* Kept deliberately explicit because this is exactly what the coach hands to the * Kept deliberately explicit because the deterministic coach UI consumes this
* LLM. The model narrates these values; it never computes them. * contract directly, and a later LLM may narrate it. Neither consumer computes
* or reconstructs poker maths.
*/ */
data class DecisionTrace( data class DecisionTrace(
/** Monte Carlo equity when equity is actually estimated; null pre-flop. */ /** Monte Carlo equity when equity is actually estimated; null pre-flop. */
@@ -30,7 +31,9 @@ data class DecisionTrace(
val potOdds: String?, val potOdds: String?,
/** What the strategy selected before a skill error was applied. */ /** What the strategy selected before a skill error was applied. */
val intended: Action, val intended: Action,
/** Action actually taken by the bot or human whose decision is reviewed. */
val chosen: Action, val chosen: Action,
/** True only when bot skill-error injection changed [intended]. */
val mistakeApplied: Boolean, val mistakeApplied: Boolean,
/** Multipliers applied to the raw threshold, in evaluation order. */ /** Multipliers applied to the raw threshold, in evaluation order. */
val adjustments: List<ThresholdAdjustment>, val adjustments: List<ThresholdAdjustment>,
@@ -99,7 +102,10 @@ class MathBot(
val looseness = (style.looseness + mood.loosenessBonus()).coerceIn(0.0, 1.0) val looseness = (style.looseness + mood.loosenessBonus()).coerceIn(0.0, 1.0)
val aggression = (style.aggression + mood.aggressionBonus()).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 // Pot odds are the honest baseline. In particular, the river has no
// future street from which to earn "implied" chips, so a blanket discount // future street from which to earn "implied" chips, so a blanket discount
@@ -161,7 +167,7 @@ class MathBot(
breakEvenEquity = breakEven, breakEvenEquity = breakEven,
decisionThreshold = bar, decisionThreshold = bar,
preflopRangeThreshold = null, 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, intended = intended,
chosen = chosen, chosen = chosen,
mistakeApplied = mistakeApplied, mistakeApplied = mistakeApplied,
@@ -204,7 +210,7 @@ class MathBot(
pct <= raiseGate && ctx.canRaise -> pct <= raiseGate && ctx.canRaise ->
Action(ActionType.RAISE, preflopRaiseTo(ctx, facingRaise)) Action(ActionType.RAISE, preflopRaiseTo(ctx, facingRaise))
pct <= gate -> 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 -> else ->
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD) 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. // Sandbagging: under-represent a monster to keep them in.
if (monster && random.nextDouble() < style.slowplayFrequency && ctx.street != Street.RIVER) { 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) { if (ctx.canCheck) {
@@ -301,7 +307,7 @@ class MathBot(
return Action(ActionType.RAISE, sizeBet(ctx, equity, aggression)) 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. */ /** Pot-fraction sizing, widening with equity and aggression. */
@@ -334,7 +340,7 @@ class MathBot(
): Action = when (intended.type) { ): Action = when (intended.type) {
ActionType.FOLD -> ActionType.FOLD ->
if (ctx.toCall > 0 && percentile <= gate + PREFLOP_ERROR_BAND) { if (ctx.toCall > 0 && percentile <= gate + PREFLOP_ERROR_BAND) {
Action(ActionType.CALL, ctx.toCall) Action(ActionType.CALL, ctx.callAmount)
} else { } else {
intended intended
} }
@@ -345,7 +351,7 @@ class MathBot(
intended intended
} }
ActionType.BET, ActionType.RAISE -> 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 ActionType.CHECK -> intended
} }
@@ -360,14 +366,14 @@ class MathBot(
rawBreakEven: Double, rawBreakEven: Double,
): Action = when (intended.type) { ): Action = when (intended.type) {
ActionType.FOLD -> 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 { ActionType.CALL -> when {
equity >= rawBreakEven -> Action(ActionType.FOLD) equity >= rawBreakEven -> Action(ActionType.FOLD)
ctx.canRaise -> Action(ActionType.RAISE, ctx.minRaiseTo) ctx.canRaise -> Action(ActionType.RAISE, ctx.minRaiseTo)
else -> intended else -> intended
} }
ActionType.BET, ActionType.RAISE -> 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 -> ActionType.CHECK ->
if (equity < 0.25 && ctx.canRaise) Action(ActionType.BET, ctx.minRaiseTo) else intended if (equity < 0.25 && ctx.canRaise) Action(ActionType.BET, ctx.minRaiseTo) else intended
} }
@@ -29,6 +29,14 @@ data class DecisionOffer(
val stack: Int, val stack: Int,
val canCheck: Boolean, val canCheck: Boolean,
val canRaise: 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. * What calling actually costs.
@@ -42,6 +50,8 @@ data class DecisionOffer(
/** True when calling would put this player all in. */ /** True when calling would put this player all in. */
val callIsAllIn: Boolean get() = toCall >= stack val callIsAllIn: Boolean get() = toCall >= stack
val inPosition: Boolean get() = seatsActingAfter == 0
} }
fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer( fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer(
@@ -58,4 +68,9 @@ fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer(
stack = stack, stack = stack,
canCheck = canCheck, canCheck = canCheck,
canRaise = canRaise, canRaise = canRaise,
activeOpponents = activeOpponents,
seatsActingAfter = seatsActingAfter,
bigBlind = bigBlind,
eligiblePot = eligiblePot,
currentBet = currentBet,
) )
@@ -77,10 +77,20 @@ class DecisionContext(
val decisionToken: Long, val decisionToken: Long,
/** TDA Rule 47: is this player facing at least a full raise since acting? */ /** TDA Rule 47: is this player facing at least a full raise since acting? */
val bettingReopened: Boolean, 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 hole: IntArray get() = seat.hole
val stack: Int get() = seat.stack val stack: Int get() = seat.stack
val canCheck: Boolean get() = toCall == 0 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. * 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 { private fun buildContext(street: Street, seat: Seat, toCall: Int): DecisionContext {
val minRaiseTo = currentBet + minRaiseSize val minRaiseTo = currentBet + minRaiseSize
val maxRaiseTo = seat.committedThisRound + seat.stack 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 after = 0
var i = (seat.index + 1) % seats.size var i = (seat.index + 1) % seats.size
@@ -468,6 +481,8 @@ class Table(
handNumber = handNumber, handNumber = handNumber,
decisionToken = pendingToken, decisionToken = pendingToken,
bettingReopened = mayRaise(seat), bettingReopened = mayRaise(seat),
eligiblePot = eligiblePot,
currentBet = currentBet,
) )
} }
@@ -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)
}
}
@@ -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)
}
} }
@@ -15,6 +15,7 @@ private data class Offer(
val canRaise: Boolean, val canRaise: Boolean,
val canCheck: Boolean, val canCheck: Boolean,
val pot: Int, val pot: Int,
val eligiblePot: Int,
val minRaiseTo: Int, val minRaiseTo: Int,
val maxRaiseTo: Int, val maxRaiseTo: Int,
) )
@@ -26,7 +27,7 @@ private class Scripted(private vararg val actions: Action) : PlayerAgent {
override suspend fun act(ctx: DecisionContext): Action { override suspend fun act(ctx: DecisionContext): Action {
offers += Offer( offers += Offer(
ctx.street, ctx.toCall, ctx.canRaise, ctx.canCheck, 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++) { return actions.getOrElse(i++) {
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD) if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
@@ -183,6 +184,32 @@ class TableRulesTest {
assertTrue(result.net[2] < 0, "queens should lose") 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 * 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 * winners cannot split evenly. TDA Rule 20 sends the odd chip to the first