Persist cross-session coach history
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LeakKind, Long> = 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<Pair<LeakKind, Long>> { 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
require(modelClass.isAssignableFrom(PokerViewModel::class.java))
|
||||
return PokerViewModel(historyStore) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user