diff --git a/CLAUDE.md b/CLAUDE.md index dbc6aa4..baba18b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,11 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" structurally separate from post-flop equity fields; inapplicable values are null. The production coach explains these values; it does not reconstruct hidden bot logic. +8. **Persist aggregates, not surveillance.** Player history stores the schema + version, setup preference, session/hand counts, baseline agreement, and stable + review-category counters. It never stores hole cards, boards, opponents, or + raw action histories. Storage mutations are serialised because hand completion + and coach analysis run on different coroutines. ## Testing notes @@ -134,6 +139,10 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" 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. +- Versioned player history survives process restarts in a small aggregate + `SharedPreferences` store. The setup screen restores the last player/coach + preference and shows cross-session agreement/review trends only after at least + ten coached decisions; smaller samples are labelled as insufficient. - Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build. - Not built yet: LLM persona layer. diff --git a/app/src/main/java/com/jsjdesigns/poker/MainActivity.kt b/app/src/main/java/com/jsjdesigns/poker/MainActivity.kt index bbf2fa8..a9e0260 100644 --- a/app/src/main/java/com/jsjdesigns/poker/MainActivity.kt +++ b/app/src/main/java/com/jsjdesigns/poker/MainActivity.kt @@ -8,11 +8,17 @@ import androidx.compose.material3.darkColorScheme import androidx.lifecycle.viewmodel.compose.viewModel class MainActivity : ComponentActivity() { + private val historyStore by lazy { + SharedPreferencesPlayerHistoryStore(applicationContext) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme(colorScheme = darkColorScheme()) { - val vm: PokerViewModel = viewModel() + val vm: PokerViewModel = viewModel( + factory = PokerViewModel.factory(historyStore), + ) PokerApp(vm) } } diff --git a/app/src/main/java/com/jsjdesigns/poker/PlayerHistory.kt b/app/src/main/java/com/jsjdesigns/poker/PlayerHistory.kt new file mode 100644 index 0000000..8735358 --- /dev/null +++ b/app/src/main/java/com/jsjdesigns/poker/PlayerHistory.kt @@ -0,0 +1,157 @@ +package com.jsjdesigns.poker + +import com.jsjdesigns.poker.bot.CoachingReview +import com.jsjdesigns.poker.game.ActionType +import com.jsjdesigns.poker.game.Street +import kotlin.math.roundToInt + +const val PLAYER_HISTORY_SCHEMA_VERSION = 1 +const val MIN_DECISIONS_FOR_TREND = 10 +const val MIN_OCCURRENCES_FOR_LEAK = 3 + +/** + * Stable, aggregate categories for cross-session coaching. + * + * The identifiers are persisted and must not be renamed. We deliberately store + * no cards, board, opponent data, or raw action history. + */ +enum class LeakKind(val id: String, val label: String) { + PREFLOP_TOO_LOOSE("preflop_too_loose", "Pre-flop continues outside the baseline"), + PREFLOP_TOO_TIGHT("preflop_too_tight", "Pre-flop folds inside the baseline"), + PREFLOP_MISSED_RAISE("preflop_missed_raise", "Passed on baseline pre-flop raises"), + PREFLOP_OVERAGGRESSION("preflop_overaggression", "Raised beyond the pre-flop baseline"), + POSTFLOP_LOOSE_CONTINUE("postflop_loose_continue", "Post-flop continues below the baseline"), + POSTFLOP_OVERFOLD("postflop_overfold", "Post-flop folds above the baseline"), + POSTFLOP_MISSED_AGGRESSION("postflop_missed_aggression", "Passed on baseline bets and raises"), + POSTFLOP_OVERAGGRESSION("postflop_overaggression", "Aggression beyond the post-flop baseline"), +} + +data class PlayerHistory( + val schemaVersion: Int = PLAYER_HISTORY_SCHEMA_VERSION, + val playerName: String = "", + val coachEnabled: Boolean = false, + val sessionsStarted: Long = 0, + val handsCompleted: Long = 0, + val decisionsReviewed: Long = 0, + val baselineAligned: Long = 0, + val leakCounts: Map = emptyMap(), +) { + init { + require(schemaVersion == PLAYER_HISTORY_SCHEMA_VERSION) + require( + listOf( + sessionsStarted, + handsCompleted, + decisionsReviewed, + baselineAligned, + ).all { it >= 0 }, + ) + require(baselineAligned <= decisionsReviewed) + require(leakCounts.values.all { it >= 0 }) + } + + fun leakCount(kind: LeakKind): Long = leakCounts[kind] ?: 0 +} + +data class HistoryPresentation( + val headline: String, + val detail: String, +) + +fun PlayerHistory.recordSession(playerName: String, coachEnabled: Boolean): PlayerHistory = + copy( + playerName = playerName, + coachEnabled = coachEnabled, + sessionsStarted = sessionsStarted + 1, + ) + +fun PlayerHistory.recordCompletedHand(): PlayerHistory = + copy(handsCompleted = handsCompleted + 1) + +fun PlayerHistory.recordCoachReview(review: CoachingReview): PlayerHistory { + val leak = classifyLeak(review) + val updatedLeaks = if (leak == null) { + leakCounts + } else { + leakCounts + (leak to leakCount(leak) + 1) + } + return copy( + decisionsReviewed = decisionsReviewed + 1, + baselineAligned = baselineAligned + if (review.alignedWithBaseline) 1 else 0, + leakCounts = updatedLeaks, + ) +} + +/** + * Maps one baseline disagreement to at most one durable trend. + * + * This is a descriptive fundamentals comparison, not solver proof that the + * player made a mistake. Bet and raise already count as the same action family + * in [CoachingReview.alignedWithBaseline]. + */ +fun classifyLeak(review: CoachingReview): LeakKind? { + if (review.alignedWithBaseline) return null + val intended = review.trace.intended.type + val chosen = review.trace.chosen.type + val intendedAggressive = intended == ActionType.BET || intended == ActionType.RAISE + val chosenAggressive = chosen == ActionType.BET || chosen == ActionType.RAISE + + return if (review.street == Street.PREFLOP) { + when { + intended == ActionType.FOLD && chosen != ActionType.FOLD -> + LeakKind.PREFLOP_TOO_LOOSE + chosen == ActionType.FOLD && intended != ActionType.FOLD -> + LeakKind.PREFLOP_TOO_TIGHT + intendedAggressive && !chosenAggressive -> + LeakKind.PREFLOP_MISSED_RAISE + chosenAggressive && !intendedAggressive -> + LeakKind.PREFLOP_OVERAGGRESSION + else -> null + } + } else { + when { + intended == ActionType.FOLD && chosen != ActionType.FOLD -> + LeakKind.POSTFLOP_LOOSE_CONTINUE + chosen == ActionType.FOLD && intended != ActionType.FOLD -> + LeakKind.POSTFLOP_OVERFOLD + intendedAggressive && !chosenAggressive -> + LeakKind.POSTFLOP_MISSED_AGGRESSION + chosenAggressive && !intendedAggressive -> + LeakKind.POSTFLOP_OVERAGGRESSION + else -> null + } + } +} + +fun historyPresentation(history: PlayerHistory): HistoryPresentation? { + if (history.sessionsStarted == 0L && history.handsCompleted == 0L) return null + + val headline = "${history.handsCompleted} hands saved • " + + "${history.decisionsReviewed} coached decisions" + if (history.decisionsReviewed < MIN_DECISIONS_FOR_TREND) { + val remaining = MIN_DECISIONS_FOR_TREND - history.decisionsReviewed + return HistoryPresentation( + headline, + "$remaining more coached ${if (remaining == 1L) "decision" else "decisions"} " + + "before calling anything a trend.", + ) + } + + val agreement = ( + history.baselineAligned.toDouble() / + history.decisionsReviewed.toDouble() * + 100.0 + ).roundToInt() + val topLeak = LeakKind.entries + .map { it to history.leakCount(it) } + .filter { it.second >= MIN_OCCURRENCES_FOR_LEAK } + .maxWithOrNull(compareBy> { it.second }.thenByDescending { it.first.ordinal }) + + val detail = if (topLeak == null) { + "Baseline agreement $agreement%. No repeated review pattern yet." + } else { + "Baseline agreement $agreement%. Most frequent review: " + + "${topLeak.first.label} (${topLeak.second})." + } + return HistoryPresentation(headline, detail) +} diff --git a/app/src/main/java/com/jsjdesigns/poker/PlayerHistoryStore.kt b/app/src/main/java/com/jsjdesigns/poker/PlayerHistoryStore.kt new file mode 100644 index 0000000..66aca96 --- /dev/null +++ b/app/src/main/java/com/jsjdesigns/poker/PlayerHistoryStore.kt @@ -0,0 +1,100 @@ +package com.jsjdesigns.poker + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import com.jsjdesigns.poker.bot.CoachingReview +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +interface PlayerHistoryStore { + suspend fun load(): PlayerHistory + suspend fun recordSession(playerName: String, coachEnabled: Boolean): PlayerHistory + suspend fun recordCompletedHand(): PlayerHistory + suspend fun recordCoachReview(review: CoachingReview): PlayerHistory +} + +/** + * Small versioned aggregate store. + * + * SharedPreferences is appropriate here because the complete schema is a few + * scalar counters. Mutations are serialised and committed on Dispatchers.IO so a + * hand completion and an asynchronous coach review cannot overwrite each other. + */ +class SharedPreferencesPlayerHistoryStore(context: Context) : PlayerHistoryStore { + private val preferences = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val mutex = Mutex() + + override suspend fun load(): PlayerHistory = withContext(Dispatchers.IO) { + mutex.withLock { preferences.readHistory() } + } + + override suspend fun recordSession( + playerName: String, + coachEnabled: Boolean, + ): PlayerHistory = mutate { it.recordSession(playerName, coachEnabled) } + + override suspend fun recordCompletedHand(): PlayerHistory = + mutate(PlayerHistory::recordCompletedHand) + + override suspend fun recordCoachReview(review: CoachingReview): PlayerHistory = + mutate { it.recordCoachReview(review) } + + private suspend fun mutate(block: (PlayerHistory) -> PlayerHistory): PlayerHistory = + withContext(Dispatchers.IO) { + mutex.withLock { + val updated = block(preferences.readHistory()) + check(preferences.writeHistory(updated)) { "could not persist player history" } + updated + } + } + + private companion object { + const val PREFERENCES_NAME = "player_history" + const val KEY_SCHEMA = "schema_version" + const val KEY_PLAYER_NAME = "player_name" + const val KEY_COACH_ENABLED = "coach_enabled" + const val KEY_SESSIONS = "sessions_started" + const val KEY_HANDS = "hands_completed" + const val KEY_DECISIONS = "decisions_reviewed" + const val KEY_ALIGNED = "baseline_aligned" + + fun SharedPreferences.readHistory(): PlayerHistory { + val storedSchema = getInt(KEY_SCHEMA, PLAYER_HISTORY_SCHEMA_VERSION) + require(storedSchema <= PLAYER_HISTORY_SCHEMA_VERSION) { + "player history schema $storedSchema is newer than supported " + + PLAYER_HISTORY_SCHEMA_VERSION + } + return PlayerHistory( + playerName = getString(KEY_PLAYER_NAME, "").orEmpty(), + coachEnabled = getBoolean(KEY_COACH_ENABLED, false), + sessionsStarted = getLong(KEY_SESSIONS, 0), + handsCompleted = getLong(KEY_HANDS, 0), + decisionsReviewed = getLong(KEY_DECISIONS, 0), + baselineAligned = getLong(KEY_ALIGNED, 0), + leakCounts = LeakKind.entries.associateWith { kind -> + getLong("leak_${kind.id}", 0) + }, + ) + } + + @SuppressLint("UseKtx") // KTX edit discards commit(), so durability failures cannot be reported. + fun SharedPreferences.writeHistory(history: PlayerHistory): Boolean { + val editor = edit() + .putInt(KEY_SCHEMA, history.schemaVersion) + .putString(KEY_PLAYER_NAME, history.playerName) + .putBoolean(KEY_COACH_ENABLED, history.coachEnabled) + .putLong(KEY_SESSIONS, history.sessionsStarted) + .putLong(KEY_HANDS, history.handsCompleted) + .putLong(KEY_DECISIONS, history.decisionsReviewed) + .putLong(KEY_ALIGNED, history.baselineAligned) + for (kind in LeakKind.entries) { + editor.putLong("leak_${kind.id}", history.leakCount(kind)) + } + return editor.commit() + } + } +} diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt b/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt index 4192af8..2d7d19a 100644 --- a/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt +++ b/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt @@ -2,6 +2,8 @@ package com.jsjdesigns.poker import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -40,22 +42,34 @@ private val TableGold = Color(0xFFE3C179) fun PokerApp(vm: PokerViewModel) { val state by vm.state.collectAsStateWithLifecycle() if (state.gameConfig == null) { - TakeASeatScreen(onStart = vm::startGame) + TakeASeatScreen( + history = state.playerHistory, + onStart = vm::startGame, + ) } else { TableScreen(vm) } } @Composable -private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) { - var playerName by rememberSaveable { mutableStateOf("") } - var coachEnabled by rememberSaveable { mutableStateOf(false) } +private fun TakeASeatScreen( + history: PlayerHistory?, + onStart: (String, Boolean) -> Unit, +) { + var playerName by rememberSaveable(history?.playerName) { + mutableStateOf(history?.playerName?.takeUnless { it == "You" }.orEmpty()) + } + var coachEnabled by rememberSaveable(history?.coachEnabled) { + mutableStateOf(history?.coachEnabled ?: false) + } + val progress = history?.let(::historyPresentation) Column( modifier = Modifier .fillMaxSize() .background(SetupFelt) .systemBarsPadding() + .verticalScroll(rememberScrollState()) .padding(24.dp), verticalArrangement = Arrangement.Center, ) { @@ -145,6 +159,25 @@ private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) { color = Color.White.copy(alpha = 0.48f), fontSize = 12.sp, ) + if (progress != null) { + Spacer(Modifier.height(14.dp)) + Text( + "YOUR COACH HISTORY", + color = TableGold, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + ) + Text( + progress.headline, + color = Color.White.copy(alpha = 0.72f), + fontSize = 12.sp, + ) + Text( + progress.detail, + color = Color.White.copy(alpha = 0.5f), + fontSize = 12.sp, + ) + } } } @@ -152,13 +185,14 @@ private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) { Button( onClick = { onStart(playerName, coachEnabled) }, + enabled = history != null, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = Color(0xFF2C6E49), contentColor = Color.White, ), ) { - Text("Buy in for $DEFAULT_BUY_IN") + Text(if (history == null) "Loading saved progress…" else "Buy in for $DEFAULT_BUY_IN") } } } diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt index 0b6b647..6f3ba8f 100644 --- a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt +++ b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt @@ -1,6 +1,7 @@ package com.jsjdesigns.poker import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.jsjdesigns.poker.bot.BotProfile import com.jsjdesigns.poker.bot.CoachingReview @@ -23,6 +24,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlin.random.Random @@ -39,6 +42,8 @@ data class UiState( val coachReview: CoachingReview? = null, /** Most recent engine-owned human decision identity observed on screen. */ val latestHeroDecisionToken: Long? = null, + /** Null only while durable aggregate history is loading. */ + val playerHistory: PlayerHistory? = null, ) { /** * The decision to show, or null. @@ -94,11 +99,14 @@ data class UiState( * suspends when the channel is full, the engine cannot outrun the animation: * backpressure does the pacing for us. */ -class PokerViewModel : ViewModel() { +class PokerViewModel( + private val historyStore: PlayerHistoryStore, +) : ViewModel() { private val human = HumanAgent() private val coach = DecisionCoach() private val nextHandGate = NextHandGate() + private val historyMutationMutex = Mutex() // 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 // offered could belong to different moments — even different hands. With no @@ -117,6 +125,16 @@ class PokerViewModel : ViewModel() { init { viewModelScope.launch { consumeFrames() } + viewModelScope.launch { + updateHistory { historyStore.load() } + } + } + + private suspend fun updateHistory(block: suspend () -> PlayerHistory) { + historyMutationMutex.withLock { + val history = block() + _state.update { it.copy(playerHistory = history) } + } } /** @@ -124,7 +142,7 @@ class PokerViewModel : ViewModel() { * A rapid double tap cannot launch two engines against the same HumanAgent. */ fun startGame(playerName: String, coachEnabled: Boolean) { - if (gameStarted) return + if (gameStarted || _state.value.playerHistory == null) return gameStarted = true val config = cashGameConfig(playerName, coachEnabled) @@ -149,7 +167,17 @@ class PokerViewModel : ViewModel() { observer = { frames.send(it) }, ) - _state.update { UiState(gameConfig = config) } + _state.update { current -> + UiState( + gameConfig = config, + playerHistory = current.playerHistory, + ) + } + viewModelScope.launch { + updateHistory { + historyStore.recordSession(config.playerName, config.coachEnabled) + } + } viewModelScope.launch(Dispatchers.Default) { playContinuously(config) } } @@ -222,6 +250,9 @@ class PokerViewModel : ViewModel() { heroNeedsRebuy = heroNeedsRebuy, ) } + viewModelScope.launch { + updateHistory { historyStore.recordCompletedHand() } + } // Do not let the next deal erase the outcome before the // player has read it and chosen to continue. nextHandGate.awaitRequest(summary.handNumber) @@ -270,6 +301,7 @@ class PokerViewModel : ViewModel() { current } } + updateHistory { historyStore.recordCoachReview(review) } } } @@ -291,4 +323,15 @@ class PokerViewModel : ViewModel() { frames.close() super.onCleared() } + + companion object { + fun factory(historyStore: PlayerHistoryStore): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(PokerViewModel::class.java)) + return PokerViewModel(historyStore) as T + } + } + } } diff --git a/app/src/test/java/com/jsjdesigns/poker/PlayerHistoryTest.kt b/app/src/test/java/com/jsjdesigns/poker/PlayerHistoryTest.kt new file mode 100644 index 0000000..e21c74e --- /dev/null +++ b/app/src/test/java/com/jsjdesigns/poker/PlayerHistoryTest.kt @@ -0,0 +1,153 @@ +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlayerHistoryTest { + + private fun review( + street: Street, + intended: ActionType, + chosen: ActionType, + aligned: Boolean = false, + ) = CoachingReview( + decisionToken = 1, + handNumber = 1, + street = street, + trace = DecisionTrace( + estimatedEquity = null, + handStrengthPercentile = null, + breakEvenEquity = null, + decisionThreshold = null, + preflopRangeThreshold = null, + potOdds = null, + intended = Action(intended), + chosen = Action(chosen), + mistakeApplied = false, + adjustments = emptyList(), + reason = "test", + ), + alignedWithBaseline = aligned, + ) + + @Test + fun `every strategic disagreement has one stable aggregate category`() { + val cases = listOf( + Triple(Street.PREFLOP, ActionType.FOLD to ActionType.CALL, LeakKind.PREFLOP_TOO_LOOSE), + Triple(Street.PREFLOP, ActionType.CALL to ActionType.FOLD, LeakKind.PREFLOP_TOO_TIGHT), + Triple(Street.PREFLOP, ActionType.RAISE to ActionType.CALL, LeakKind.PREFLOP_MISSED_RAISE), + Triple(Street.PREFLOP, ActionType.CALL to ActionType.RAISE, LeakKind.PREFLOP_OVERAGGRESSION), + Triple(Street.RIVER, ActionType.FOLD to ActionType.CALL, LeakKind.POSTFLOP_LOOSE_CONTINUE), + Triple(Street.RIVER, ActionType.CALL to ActionType.FOLD, LeakKind.POSTFLOP_OVERFOLD), + Triple(Street.RIVER, ActionType.BET to ActionType.CHECK, LeakKind.POSTFLOP_MISSED_AGGRESSION), + Triple(Street.RIVER, ActionType.CHECK to ActionType.BET, LeakKind.POSTFLOP_OVERAGGRESSION), + ) + + for ((street, actions, expected) in cases) { + assertEquals( + expected, + classifyLeak(review(street, actions.first, actions.second)), + ) + } + } + + @Test + fun `persisted leak identifiers are unique and version one stable`() { + assertEquals(1, PLAYER_HISTORY_SCHEMA_VERSION) + assertEquals( + listOf( + "preflop_too_loose", + "preflop_too_tight", + "preflop_missed_raise", + "preflop_overaggression", + "postflop_loose_continue", + "postflop_overfold", + "postflop_missed_aggression", + "postflop_overaggression", + ), + LeakKind.entries.map(LeakKind::id), + ) + assertEquals(LeakKind.entries.size, LeakKind.entries.map(LeakKind::id).toSet().size) + } + + @Test + fun `aligned decisions improve agreement without inventing a leak`() { + val aligned = review( + street = Street.FLOP, + intended = ActionType.BET, + chosen = ActionType.RAISE, + aligned = true, + ) + val history = PlayerHistory().recordCoachReview(aligned) + + assertEquals(1L, history.decisionsReviewed) + assertEquals(1L, history.baselineAligned) + assertTrue(history.leakCounts.isEmpty()) + assertNull(classifyLeak(aligned)) + } + + @Test + fun `session preferences hands and reviews aggregate without raw hand data`() { + val looseCall = review(Street.TURN, ActionType.FOLD, ActionType.CALL) + val history = PlayerHistory() + .recordSession("Jay", coachEnabled = true) + .recordCompletedHand() + .recordCompletedHand() + .recordCoachReview(looseCall) + .recordCoachReview(looseCall) + + assertEquals("Jay", history.playerName) + assertTrue(history.coachEnabled) + assertEquals(1L, history.sessionsStarted) + assertEquals(2L, history.handsCompleted) + assertEquals(2L, history.decisionsReviewed) + assertEquals(2L, history.leakCount(LeakKind.POSTFLOP_LOOSE_CONTINUE)) + } + + @Test + fun `history refuses to call a small sample a trend`() { + val history = PlayerHistory( + sessionsStarted = 1, + handsCompleted = 4, + decisionsReviewed = 3, + baselineAligned = 1, + leakCounts = mapOf(LeakKind.POSTFLOP_OVERFOLD to 2), + ) + val presentation = historyPresentation(history)!! + + assertEquals("4 hands saved • 3 coached decisions", presentation.headline) + assertTrue("7 more coached decisions" in presentation.detail) + assertTrue("trend" in presentation.detail) + } + + @Test + fun `mature history reports agreement and the most repeated review`() { + val history = PlayerHistory( + sessionsStarted = 3, + handsCompleted = 42, + decisionsReviewed = 20, + baselineAligned = 13, + leakCounts = mapOf( + LeakKind.PREFLOP_TOO_LOOSE to 3, + LeakKind.POSTFLOP_OVERFOLD to 4, + ), + ) + val presentation = historyPresentation(history)!! + + assertTrue("Baseline agreement 65%" in presentation.detail) + assertTrue(LeakKind.POSTFLOP_OVERFOLD.label in presentation.detail) + assertTrue("(4)" in presentation.detail) + } + + @Test + fun `brand new player has no fake history card`() { + assertNull(historyPresentation(PlayerHistory())) + } +}