Add deterministic post-action coach
This commit is contained in:
@@ -7,6 +7,7 @@ internal const val DEFAULT_BIG_BLIND = 2
|
||||
|
||||
data class CashGameConfig(
|
||||
val playerName: String,
|
||||
val coachEnabled: Boolean = false,
|
||||
val buyIn: Int = DEFAULT_BUY_IN,
|
||||
val smallBlind: Int = DEFAULT_SMALL_BLIND,
|
||||
val bigBlind: Int = DEFAULT_BIG_BLIND,
|
||||
@@ -18,13 +19,19 @@ data class CashGameConfig(
|
||||
* "You" remains the safe default, but a supplied name is trimmed, internal
|
||||
* whitespace is collapsed, and the table-sized label is bounded.
|
||||
*/
|
||||
fun cashGameConfig(playerName: String): CashGameConfig {
|
||||
fun cashGameConfig(
|
||||
playerName: String,
|
||||
coachEnabled: Boolean = false,
|
||||
): CashGameConfig {
|
||||
val normalised = playerName
|
||||
.trim()
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.take(MAX_PLAYER_NAME_LENGTH)
|
||||
.ifBlank { "You" }
|
||||
return CashGameConfig(playerName = normalised)
|
||||
return CashGameConfig(
|
||||
playerName = normalised,
|
||||
coachEnabled = coachEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -13,6 +15,8 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -43,8 +47,9 @@ fun PokerApp(vm: PokerViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TakeASeatScreen(onStart: (String) -> Unit) {
|
||||
private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) {
|
||||
var playerName by rememberSaveable { mutableStateOf("") }
|
||||
var coachEnabled by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -90,6 +95,34 @@ private fun TakeASeatScreen(onStart: (String) -> Unit) {
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { coachEnabled = !coachEnabled }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = coachEnabled,
|
||||
onCheckedChange = null,
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = TableGold,
|
||||
checkmarkColor = SetupFelt,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.55f),
|
||||
),
|
||||
)
|
||||
Column {
|
||||
Text("Post-action coach", color = Color.White, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
"Reviews your choice after you act. No hints during decisions.",
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.08f)),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -118,7 +151,7 @@ private fun TakeASeatScreen(onStart: (String) -> Unit) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onStart(playerName) },
|
||||
onClick = { onStart(playerName, coachEnabled) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2C6E49),
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.jsjdesigns.poker
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jsjdesigns.poker.bot.BotProfile
|
||||
import com.jsjdesigns.poker.bot.CoachingReview
|
||||
import com.jsjdesigns.poker.bot.DecisionCoach
|
||||
import com.jsjdesigns.poker.bot.MathBot
|
||||
import com.jsjdesigns.poker.bot.PlayStyle
|
||||
import com.jsjdesigns.poker.bot.SkillLevel
|
||||
@@ -21,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.random.Random
|
||||
|
||||
const val HERO_SEAT = 0
|
||||
@@ -33,6 +36,9 @@ data class UiState(
|
||||
val handSummary: HandSummary? = null,
|
||||
val nextHandRequested: Boolean = false,
|
||||
val heroNeedsRebuy: Boolean = false,
|
||||
val coachReview: CoachingReview? = null,
|
||||
/** Most recent engine-owned human decision identity observed on screen. */
|
||||
val latestHeroDecisionToken: Long? = null,
|
||||
) {
|
||||
/**
|
||||
* The decision to show, or null.
|
||||
@@ -62,6 +68,18 @@ data class UiState(
|
||||
snap.phase == TableSnapshot.Phase.COMPLETE
|
||||
return handSummary.takeIf { terminal && it?.handNumber == snap.handNumber }
|
||||
}
|
||||
|
||||
/**
|
||||
* An asynchronous review is accepted only while its exact decision remains
|
||||
* the latest human decision in the displayed hand.
|
||||
*/
|
||||
fun acceptsCoachReview(review: CoachingReview): Boolean =
|
||||
gameConfig?.coachEnabled == true &&
|
||||
latestHeroDecisionToken == review.decisionToken &&
|
||||
snapshot?.handNumber == review.handNumber
|
||||
|
||||
fun visibleCoachReview(): CoachingReview? =
|
||||
coachReview.takeIf { it != null && acceptsCoachReview(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +97,7 @@ data class UiState(
|
||||
class PokerViewModel : ViewModel() {
|
||||
|
||||
private val human = HumanAgent()
|
||||
private val coach = DecisionCoach()
|
||||
private val nextHandGate = NextHandGate()
|
||||
// RENDEZVOUS, not a buffer. A 32-deep queue let the engine race dozens of
|
||||
// frames ahead of the animation, so the board on screen and the action being
|
||||
@@ -104,11 +123,11 @@ class PokerViewModel : ViewModel() {
|
||||
* Creates the table exactly once, after the player chooses to take a seat.
|
||||
* A rapid double tap cannot launch two engines against the same HumanAgent.
|
||||
*/
|
||||
fun startGame(playerName: String) {
|
||||
fun startGame(playerName: String, coachEnabled: Boolean) {
|
||||
if (gameStarted) return
|
||||
gameStarted = true
|
||||
|
||||
val config = cashGameConfig(playerName)
|
||||
val config = cashGameConfig(playerName, coachEnabled)
|
||||
val roster = listOf(
|
||||
BotProfile(config.playerName, SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE),
|
||||
BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."),
|
||||
@@ -138,6 +157,11 @@ class PokerViewModel : ViewModel() {
|
||||
for (frame in frames) {
|
||||
_state.update { current ->
|
||||
val sameCompletedHand = current.handSummary?.handNumber == frame.handNumber
|
||||
val heroDecisionToken =
|
||||
frame.toActToken.takeIf { frame.toAct == HERO_SEAT }
|
||||
val isNewHeroDecision =
|
||||
heroDecisionToken != null &&
|
||||
heroDecisionToken != current.latestHeroDecisionToken
|
||||
current.copy(
|
||||
snapshot = frame.maskedFor(HERO_SEAT),
|
||||
// Keep the result visible after the tap until the new deal
|
||||
@@ -146,6 +170,11 @@ class PokerViewModel : ViewModel() {
|
||||
handSummary = current.handSummary.takeIf { sameCompletedHand },
|
||||
nextHandRequested = current.nextHandRequested && sameCompletedHand,
|
||||
heroNeedsRebuy = current.heroNeedsRebuy && sameCompletedHand,
|
||||
coachReview = current.coachReview.takeIf {
|
||||
it?.handNumber == frame.handNumber && !isNewHeroDecision
|
||||
},
|
||||
latestHeroDecisionToken =
|
||||
heroDecisionToken ?: current.latestHeroDecisionToken,
|
||||
)
|
||||
}
|
||||
delay(pacingMillis(frame))
|
||||
@@ -225,7 +254,23 @@ class PokerViewModel : ViewModel() {
|
||||
* doubled tap is dropped rather than applied to whatever comes next.
|
||||
*/
|
||||
fun submit(token: Long, action: Action) {
|
||||
viewModelScope.launch { human.submit(token, action) }
|
||||
val decision = offer.value?.takeIf { it.token == token }
|
||||
viewModelScope.launch {
|
||||
val accepted = human.submit(token, action)
|
||||
val config = _state.value.gameConfig
|
||||
if (!accepted || decision == null || config?.coachEnabled != true) return@launch
|
||||
|
||||
val review = withContext(Dispatchers.Default) {
|
||||
coach.review(decision, action)
|
||||
}
|
||||
_state.update { current ->
|
||||
if (current.acceptsCoachReview(review)) {
|
||||
current.copy(coachReview = review)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fold(token: Long) = submit(token, Action(ActionType.FOLD))
|
||||
|
||||
@@ -51,6 +51,7 @@ fun TableScreen(vm: PokerViewModel) {
|
||||
// Only show an action bar that belongs to the table currently on screen.
|
||||
val offer = state.liveOffer(rawOffer)
|
||||
val handSummary = state.visibleHandSummary()
|
||||
val coachReview = state.visibleCoachReview()
|
||||
val status = snap?.let(::tableStatus)
|
||||
|
||||
Column(
|
||||
@@ -147,6 +148,7 @@ fun TableScreen(vm: PokerViewModel) {
|
||||
nextHandRequested = state.nextHandRequested,
|
||||
rebuyRequired = state.heroNeedsRebuy,
|
||||
buyIn = state.gameConfig?.buyIn ?: DEFAULT_BUY_IN,
|
||||
coachReview = coachReview,
|
||||
heroFolded = hero?.folded == true,
|
||||
onFold = vm::fold,
|
||||
onCheckCall = vm::checkOrCall,
|
||||
@@ -247,6 +249,7 @@ private fun ActionBar(
|
||||
nextHandRequested: Boolean,
|
||||
rebuyRequired: Boolean,
|
||||
buyIn: Int,
|
||||
coachReview: com.jsjdesigns.poker.bot.CoachingReview?,
|
||||
heroFolded: Boolean,
|
||||
onFold: (Long) -> Unit,
|
||||
onCheckCall: (Long) -> Unit,
|
||||
@@ -262,6 +265,8 @@ private fun ActionBar(
|
||||
buyIn = buyIn,
|
||||
onNextHand = onNextHand,
|
||||
)
|
||||
} else if (coachReview != null) {
|
||||
CoachBar(coachPresentation(coachReview))
|
||||
} else {
|
||||
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
@@ -335,6 +340,30 @@ private fun ActionBar(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CoachBar(presentation: CoachPresentation) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF14357A).copy(alpha = 0.48f)),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Text(
|
||||
"COACH • ${presentation.title}",
|
||||
color = Color(0xFFE3C179),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Text(
|
||||
presentation.detail,
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandResultBar(
|
||||
summary: HandSummary,
|
||||
|
||||
@@ -10,6 +10,12 @@ class CashGameSessionTest {
|
||||
@Test
|
||||
fun `blank player name has an honest second-person fallback`() {
|
||||
assertEquals("You", cashGameConfig(" ").playerName)
|
||||
assertFalse("coach must be opt-in", cashGameConfig("Jay").coachEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coach preference crosses the session boundary explicitly`() {
|
||||
assertTrue(cashGameConfig("Jay", coachEnabled = true).coachEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user