Add deterministic post-action coach
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user