Make player history failures non-fatal

This commit is contained in:
Jay
2026-07-26 19:40:58 -04:00
parent 1ec3dd1276
commit 843957d154
3 changed files with 262 additions and 53 deletions
+6 -2
View File
@@ -72,7 +72,9 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
version, setup preference, session/hand counts, baseline agreement, and stable version, setup preference, session/hand counts, baseline agreement, and stable
review-category counters. It never stores hole cards, boards, opponents, or review-category counters. It never stores hole cards, boards, opponents, or
raw action histories. Storage mutations are serialised because hand completion raw action histories. Storage mutations are serialised because hand completion
and coach analysis run on different coroutines. and coach analysis run on different coroutines. History is non-critical: an
unreadable/newer schema or failed commit falls back to in-memory counters and
disables persistence for that process rather than crashing or overwriting data.
## Testing notes ## Testing notes
@@ -142,7 +144,9 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
- Versioned player history survives process restarts in a small aggregate - Versioned player history survives process restarts in a small aggregate
`SharedPreferences` store. The setup screen restores the last player/coach `SharedPreferences` store. The setup screen restores the last player/coach
preference and shows cross-session agreement/review trends only after at least preference and shows cross-session agreement/review trends only after at least
ten coached decisions; smaller samples are labelled as insufficient. ten coached decisions; smaller samples are labelled as insufficient. Schema
reads go through an explicit migration dispatcher; add the migration branch
before incrementing `PLAYER_HISTORY_SCHEMA_VERSION`.
- 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.
@@ -3,6 +3,7 @@ package com.jsjdesigns.poker
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import com.jsjdesigns.poker.bot.CoachingReview import com.jsjdesigns.poker.bot.CoachingReview
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -16,20 +17,29 @@ interface PlayerHistoryStore {
suspend fun recordCoachReview(review: CoachingReview): PlayerHistory suspend fun recordCoachReview(review: CoachingReview): PlayerHistory
} }
internal interface PlayerHistoryPersistence {
fun read(): PlayerHistory
fun write(history: PlayerHistory): Boolean
}
/** /**
* Small versioned aggregate store. * Serialised read-modify-write with a deliberately non-fatal failure policy.
* *
* SharedPreferences is appropriate here because the complete schema is a few * History is coaching metadata, never game-critical state. If data is corrupt,
* scalar counters. Mutations are serialised and committed on Dispatchers.IO so a * belongs to a newer app schema, or cannot be written, the current process keeps
* hand completion and an asynchronous coach review cannot overwrite each other. * a fresh in-memory history and stops touching durable storage. In particular,
* an older sideloaded APK neither crashes nor overwrites a newer schema.
*/ */
class SharedPreferencesPlayerHistoryStore(context: Context) : PlayerHistoryStore { internal class ResilientPlayerHistoryStore(
private val preferences = private val persistence: PlayerHistoryPersistence,
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) private val failureReporter: (message: String, error: RuntimeException?) -> Unit = { _, _ -> },
) : PlayerHistoryStore {
private val mutex = Mutex() private val mutex = Mutex()
private var cachedHistory: PlayerHistory? = null
private var persistenceAvailable = true
override suspend fun load(): PlayerHistory = withContext(Dispatchers.IO) { override suspend fun load(): PlayerHistory = withContext(Dispatchers.IO) {
mutex.withLock { preferences.readHistory() } mutex.withLock { currentHistory() }
} }
override suspend fun recordSession( override suspend fun recordSession(
@@ -46,55 +56,128 @@ class SharedPreferencesPlayerHistoryStore(context: Context) : PlayerHistoryStore
private suspend fun mutate(block: (PlayerHistory) -> PlayerHistory): PlayerHistory = private suspend fun mutate(block: (PlayerHistory) -> PlayerHistory): PlayerHistory =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
mutex.withLock { mutex.withLock {
val updated = block(preferences.readHistory()) val updated = block(currentHistory())
check(preferences.writeHistory(updated)) { "could not persist player history" } cachedHistory = updated
if (persistenceAvailable) {
try {
if (!persistence.write(updated)) {
disablePersistence("Could not commit player history", null)
}
} catch (error: RuntimeException) {
disablePersistence("Could not write player history", error)
}
}
updated updated
} }
} }
private companion object { private fun currentHistory(): PlayerHistory {
const val PREFERENCES_NAME = "player_history" cachedHistory?.let { return it }
const val KEY_SCHEMA = "schema_version" val loaded = if (persistenceAvailable) {
const val KEY_PLAYER_NAME = "player_name" try {
const val KEY_COACH_ENABLED = "coach_enabled" persistence.read()
const val KEY_SESSIONS = "sessions_started" } catch (error: RuntimeException) {
const val KEY_HANDS = "hands_completed" disablePersistence("Could not read player history", error)
const val KEY_DECISIONS = "decisions_reviewed" PlayerHistory()
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( } else {
playerName = getString(KEY_PLAYER_NAME, "").orEmpty(), PlayerHistory()
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)
},
)
} }
cachedHistory = loaded
return loaded
}
@SuppressLint("UseKtx") // KTX edit discards commit(), so durability failures cannot be reported. private fun disablePersistence(message: String, error: RuntimeException?) {
fun SharedPreferences.writeHistory(history: PlayerHistory): Boolean { persistenceAvailable = false
val editor = edit() // Reporting must not turn a handled storage failure back into a crash.
.putInt(KEY_SCHEMA, history.schemaVersion) runCatching { failureReporter(message, error) }
.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()
}
} }
} }
class SharedPreferencesPlayerHistoryStore(context: Context) : PlayerHistoryStore by
ResilientPlayerHistoryStore(
persistence = SharedPreferencesHistoryPersistence(
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE),
),
failureReporter = { message, error ->
if (error == null) Log.e(LOG_TAG, message) else Log.e(LOG_TAG, message, error)
},
)
internal enum class HistorySchemaPlan {
FRESH,
READ_VERSION_1,
UNSUPPORTED,
}
/**
* Explicit migration dispatch. Add a branch and migration before incrementing
* [PLAYER_HISTORY_SCHEMA_VERSION]; never reinterpret a non-additive schema.
*/
internal fun historySchemaPlan(storedVersion: Int): HistorySchemaPlan = when (storedVersion) {
0 -> HistorySchemaPlan.FRESH
1 -> HistorySchemaPlan.READ_VERSION_1
else -> HistorySchemaPlan.UNSUPPORTED
}
internal class UnsupportedPlayerHistorySchema(storedVersion: Int) :
IllegalStateException(
"player history schema $storedVersion is not supported by schema " +
PLAYER_HISTORY_SCHEMA_VERSION,
)
private class SharedPreferencesHistoryPersistence(
private val preferences: SharedPreferences,
) : PlayerHistoryPersistence {
override fun read(): PlayerHistory {
val storedSchema = if (preferences.contains(KEY_SCHEMA)) {
preferences.getInt(KEY_SCHEMA, 0)
} else {
0
}
return when (historySchemaPlan(storedSchema)) {
HistorySchemaPlan.FRESH -> PlayerHistory()
HistorySchemaPlan.READ_VERSION_1 -> preferences.readVersion1()
HistorySchemaPlan.UNSUPPORTED ->
throw UnsupportedPlayerHistorySchema(storedSchema)
}
}
@SuppressLint("UseKtx") // KTX edit discards commit(), so failures cannot be reported.
override fun write(history: PlayerHistory): Boolean {
val editor = preferences.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()
}
private fun SharedPreferences.readVersion1(): PlayerHistory = 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)
},
)
}
private const val PREFERENCES_NAME = "player_history"
private const val LOG_TAG = "PlayerHistory"
private const val KEY_SCHEMA = "schema_version"
private const val KEY_PLAYER_NAME = "player_name"
private const val KEY_COACH_ENABLED = "coach_enabled"
private const val KEY_SESSIONS = "sessions_started"
private const val KEY_HANDS = "hands_completed"
private const val KEY_DECISIONS = "decisions_reviewed"
private const val KEY_ALIGNED = "baseline_aligned"
@@ -0,0 +1,122 @@
package com.jsjdesigns.poker
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PlayerHistoryStoreTest {
private class FakePersistence(
var stored: PlayerHistory = PlayerHistory(),
var readFailure: RuntimeException? = null,
var writeResult: Boolean = true,
var writeFailure: RuntimeException? = null,
) : PlayerHistoryPersistence {
var reads = 0
var writes = 0
override fun read(): PlayerHistory {
reads++
readFailure?.let { throw it }
return stored
}
override fun write(history: PlayerHistory): Boolean {
writes++
writeFailure?.let { throw it }
if (writeResult) stored = history
return writeResult
}
}
@Test
fun `schema dispatch has an explicit migration branch`() {
assertEquals(HistorySchemaPlan.FRESH, historySchemaPlan(0))
assertEquals(HistorySchemaPlan.READ_VERSION_1, historySchemaPlan(1))
assertEquals(HistorySchemaPlan.UNSUPPORTED, historySchemaPlan(2))
assertEquals(HistorySchemaPlan.UNSUPPORTED, historySchemaPlan(99))
assertEquals(HistorySchemaPlan.UNSUPPORTED, historySchemaPlan(-1))
}
@Test
fun `newer schema falls back in memory without overwrite or crash`() = runTest {
val persistence = FakePersistence(
readFailure = UnsupportedPlayerHistorySchema(99),
)
val failures = mutableListOf<String>()
val store = ResilientPlayerHistoryStore(persistence) { message, _ ->
failures += message
}
assertEquals(PlayerHistory(), store.load())
val currentSession = store.recordSession("Jay", coachEnabled = true)
assertEquals("Jay", currentSession.playerName)
assertTrue(currentSession.coachEnabled)
assertEquals(1L, currentSession.sessionsStarted)
assertEquals("fallback remains useful in this process", currentSession, store.load())
assertEquals(1, persistence.reads)
assertEquals("an older build must not overwrite newer data", 0, persistence.writes)
assertEquals(1, failures.size)
}
@Test
fun `corrupt history also degrades to an in-memory session`() = runTest {
val persistence = FakePersistence(
readFailure = IllegalArgumentException("corrupt counter"),
)
val store = ResilientPlayerHistoryStore(persistence)
assertEquals(PlayerHistory(), store.load())
assertEquals(1L, store.recordCompletedHand().handsCompleted)
assertEquals(0, persistence.writes)
}
@Test
fun `failed commit disables further writes but keeps current counters`() = runTest {
val persistence = FakePersistence(writeResult = false)
val failures = mutableListOf<String>()
val store = ResilientPlayerHistoryStore(persistence) { message, _ ->
failures += message
}
assertEquals(1L, store.recordCompletedHand().handsCompleted)
assertEquals(2L, store.recordCompletedHand().handsCompleted)
assertEquals("do not repeatedly hammer failed storage", 1, persistence.writes)
assertEquals(1, failures.size)
assertTrue("commit" in failures.single())
}
@Test
fun `write exception is contained like a false commit`() = runTest {
val persistence = FakePersistence(
writeFailure = IllegalStateException("disk unavailable"),
)
val store = ResilientPlayerHistoryStore(persistence)
assertEquals(1L, store.recordCompletedHand().handsCompleted)
assertEquals(2L, store.recordCompletedHand().handsCompleted)
assertEquals(1, persistence.writes)
}
@Test
fun `concurrent hand updates cannot clobber one another`() = runTest {
val persistence = FakePersistence()
val store = ResilientPlayerHistoryStore(persistence)
coroutineScope {
repeat(100) {
launch { store.recordCompletedHand() }
}
}
assertEquals(100L, store.load().handsCompleted)
assertEquals(100, persistence.writes)
assertFalse(persistence.stored.coachEnabled)
}
}