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
|
structurally separate from post-flop equity fields; inapplicable values are
|
||||||
null. The production coach explains these values; it does not reconstruct
|
null. The production coach explains these values; it does not reconstruct
|
||||||
hidden bot logic.
|
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
|
## 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
|
table state but no opponent hole cards. Post-flop equity is explicitly labelled
|
||||||
as an estimate against unknown random hands; disagreement is "worth reviewing,"
|
as an estimate against unknown random hands; disagreement is "worth reviewing,"
|
||||||
never declared solver proof of a mistake.
|
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
|
- 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.
|
- Not built yet: LLM persona layer.
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ import androidx.compose.material3.darkColorScheme
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val historyStore by lazy {
|
||||||
|
SharedPreferencesPlayerHistoryStore(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
val vm: PokerViewModel = viewModel()
|
val vm: PokerViewModel = viewModel(
|
||||||
|
factory = PokerViewModel.factory(historyStore),
|
||||||
|
)
|
||||||
PokerApp(vm)
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -40,22 +42,34 @@ private val TableGold = Color(0xFFE3C179)
|
|||||||
fun PokerApp(vm: PokerViewModel) {
|
fun PokerApp(vm: PokerViewModel) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
if (state.gameConfig == null) {
|
if (state.gameConfig == null) {
|
||||||
TakeASeatScreen(onStart = vm::startGame)
|
TakeASeatScreen(
|
||||||
|
history = state.playerHistory,
|
||||||
|
onStart = vm::startGame,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
TableScreen(vm)
|
TableScreen(vm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) {
|
private fun TakeASeatScreen(
|
||||||
var playerName by rememberSaveable { mutableStateOf("") }
|
history: PlayerHistory?,
|
||||||
var coachEnabled by rememberSaveable { mutableStateOf(false) }
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(SetupFelt)
|
.background(SetupFelt)
|
||||||
.systemBarsPadding()
|
.systemBarsPadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
@@ -145,6 +159,25 @@ private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) {
|
|||||||
color = Color.White.copy(alpha = 0.48f),
|
color = Color.White.copy(alpha = 0.48f),
|
||||||
fontSize = 12.sp,
|
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(
|
Button(
|
||||||
onClick = { onStart(playerName, coachEnabled) },
|
onClick = { onStart(playerName, coachEnabled) },
|
||||||
|
enabled = history != null,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color(0xFF2C6E49),
|
containerColor = Color(0xFF2C6E49),
|
||||||
contentColor = Color.White,
|
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
|
package com.jsjdesigns.poker
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
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.CoachingReview
|
||||||
@@ -23,6 +24,8 @@ 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.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@@ -39,6 +42,8 @@ data class UiState(
|
|||||||
val coachReview: CoachingReview? = null,
|
val coachReview: CoachingReview? = null,
|
||||||
/** Most recent engine-owned human decision identity observed on screen. */
|
/** Most recent engine-owned human decision identity observed on screen. */
|
||||||
val latestHeroDecisionToken: Long? = null,
|
val latestHeroDecisionToken: Long? = null,
|
||||||
|
/** Null only while durable aggregate history is loading. */
|
||||||
|
val playerHistory: PlayerHistory? = null,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* The decision to show, or 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:
|
* suspends when the channel is full, the engine cannot outrun the animation:
|
||||||
* backpressure does the pacing for us.
|
* backpressure does the pacing for us.
|
||||||
*/
|
*/
|
||||||
class PokerViewModel : ViewModel() {
|
class PokerViewModel(
|
||||||
|
private val historyStore: PlayerHistoryStore,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
private val human = HumanAgent()
|
private val human = HumanAgent()
|
||||||
private val coach = DecisionCoach()
|
private val coach = DecisionCoach()
|
||||||
private val nextHandGate = NextHandGate()
|
private val nextHandGate = NextHandGate()
|
||||||
|
private val historyMutationMutex = Mutex()
|
||||||
// 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
|
||||||
// offered could belong to different moments — even different hands. With no
|
// offered could belong to different moments — even different hands. With no
|
||||||
@@ -117,6 +125,16 @@ class PokerViewModel : ViewModel() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch { consumeFrames() }
|
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.
|
* A rapid double tap cannot launch two engines against the same HumanAgent.
|
||||||
*/
|
*/
|
||||||
fun startGame(playerName: String, coachEnabled: Boolean) {
|
fun startGame(playerName: String, coachEnabled: Boolean) {
|
||||||
if (gameStarted) return
|
if (gameStarted || _state.value.playerHistory == null) return
|
||||||
gameStarted = true
|
gameStarted = true
|
||||||
|
|
||||||
val config = cashGameConfig(playerName, coachEnabled)
|
val config = cashGameConfig(playerName, coachEnabled)
|
||||||
@@ -149,7 +167,17 @@ class PokerViewModel : ViewModel() {
|
|||||||
observer = { frames.send(it) },
|
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) }
|
viewModelScope.launch(Dispatchers.Default) { playContinuously(config) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +250,9 @@ class PokerViewModel : ViewModel() {
|
|||||||
heroNeedsRebuy = heroNeedsRebuy,
|
heroNeedsRebuy = heroNeedsRebuy,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
updateHistory { historyStore.recordCompletedHand() }
|
||||||
|
}
|
||||||
// Do not let the next deal erase the outcome before the
|
// Do not let the next deal erase the outcome before the
|
||||||
// player has read it and chosen to continue.
|
// player has read it and chosen to continue.
|
||||||
nextHandGate.awaitRequest(summary.handNumber)
|
nextHandGate.awaitRequest(summary.handNumber)
|
||||||
@@ -270,6 +301,7 @@ class PokerViewModel : ViewModel() {
|
|||||||
current
|
current
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateHistory { historyStore.recordCoachReview(review) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,4 +323,15 @@ class PokerViewModel : ViewModel() {
|
|||||||
frames.close()
|
frames.close()
|
||||||
super.onCleared()
|
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