implement quiz domain engine

This commit is contained in:
pj committed 2026-09-09 23:44:40 +05:30
1 parent bad6bce051
commit b0b57b8535
13 files changed
+1514

No files matched your search

@@ -0,0 +1,51 @@
package app.bertsatlas.domain
import app.bertsatlas.domain.model.AdaptiveState
import app.bertsatlas.domain.model.DifficultyTier
object AdaptiveDifficulty {
fun afterAnswer(current: AdaptiveState, correct: Boolean, hinted: Boolean): AdaptiveState {
if (correct && !hinted) {
val correctCount = current.consecutiveUnhintedCorrect + 1
if (correctCount >= 3) {
return current.copy(
tier = current.tier.raise(),
consecutiveUnhintedCorrect = 0,
consecutiveIncorrect = 0,
)
}
return current.copy(
consecutiveUnhintedCorrect = correctCount,
consecutiveIncorrect = 0,
)
}
if (!correct) {
val incorrectCount = current.consecutiveIncorrect + 1
if (incorrectCount >= 2) {
return current.copy(
tier = current.tier.lower(),
consecutiveUnhintedCorrect = 0,
consecutiveIncorrect = 0,
)
}
return current.copy(
consecutiveUnhintedCorrect = 0,
consecutiveIncorrect = incorrectCount,
)
}
return current.copy(
consecutiveUnhintedCorrect = 0,
consecutiveIncorrect = 0,
)
}
private fun DifficultyTier.raise(): DifficultyTier = when (this) {
DifficultyTier.Casual -> DifficultyTier.Tricky
DifficultyTier.Tricky, DifficultyTier.Brutal -> DifficultyTier.Brutal
}
private fun DifficultyTier.lower(): DifficultyTier = when (this) {
DifficultyTier.Brutal -> DifficultyTier.Tricky
DifficultyTier.Tricky, DifficultyTier.Casual -> DifficultyTier.Casual
}
}
@@ -0,0 +1,86 @@
package app.bertsatlas.domain
import app.bertsatlas.domain.model.ActiveRunSnapshot
import app.bertsatlas.domain.model.Attempt
import app.bertsatlas.domain.model.Catalog
import app.bertsatlas.domain.model.Mastery
import app.bertsatlas.domain.model.PassportStamp
import app.bertsatlas.domain.model.ProgressSummary
import app.bertsatlas.domain.model.QuizConfiguration
import app.bertsatlas.domain.model.QuizDuration
import app.bertsatlas.domain.model.QuizTopic
import app.bertsatlas.domain.model.RunResult
import app.bertsatlas.domain.model.Streak
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
interface CatalogProvider {
suspend fun loadCatalog(): Catalog
}
interface ProgressReader {
suspend fun mastery(): List<Mastery>
fun observeProgress(): Flow<ProgressSummary>
}
interface ProgressWriter {
suspend fun recordAcceptedAnswer(
attempt: Attempt,
updatedMastery: List<Mastery>,
activeRunSnapshot: ActiveRunSnapshot,
)
suspend fun recordCompletedRun(
result: RunResult,
streak: Streak,
newlyEarnedStamps: List<PassportStamp>,
)
suspend fun recordAbandonedRun(runId: String)
suspend fun resetProgress()
}
interface ActiveRunRepository {
suspend fun loadActiveRun(): ActiveRunSnapshot?
suspend fun saveActiveRun(snapshot: ActiveRunSnapshot)
suspend fun clearActiveRun(runId: String)
}
@Serializable
data class PlayerSettings(
val soundEnabled: Boolean = true,
val hapticsEnabled: Boolean = true,
val motionEnabled: Boolean = true,
val automaticAdvanceEnabled: Boolean = true,
)
interface LaunchPreferences {
suspend fun hasLaunchedBefore(): Boolean
suspend fun markLaunched()
}
interface QuizConfigurationPreferences {
suspend fun loadConfiguration(): QuizConfiguration
suspend fun saveConfiguration(duration: QuizDuration, topic: QuizTopic)
}
interface PlayerSettingsRepository {
fun observeSettings(): Flow<PlayerSettings>
suspend fun saveSettings(settings: PlayerSettings)
}
interface AudioPlayer {
fun playCorrect()
fun playIncorrect()
fun playOvertime()
}
interface HapticPlayer {
fun warnQuestionTimeExpiring()
fun signalCorrect()
fun signalIncorrect()
}
fun interface ReducedMotionProvider {
fun isReducedMotionEnabled(): Boolean
}
@@ -0,0 +1,133 @@
package app.bertsatlas.domain
import app.bertsatlas.domain.model.Attempt
import app.bertsatlas.domain.model.Continent
import app.bertsatlas.domain.model.Mastery
import app.bertsatlas.domain.model.MasteryLevel
import app.bertsatlas.domain.model.MasteryOutcome
import app.bertsatlas.domain.model.PassportStamp
import app.bertsatlas.domain.model.PassportStampKind
import app.bertsatlas.domain.model.Streak
object MasteryRules {
fun applyAttempt(current: Mastery?, attempt: Attempt): Mastery {
require(attempt.countryIds.isNotEmpty()) { "A mastery attempt requires a country" }
val existing = current ?: Mastery(
countryId = attempt.countryIds.first(),
skill = attempt.skill,
)
val outcome = MasteryOutcome(
correct = attempt.correct,
hinted = attempt.hinted,
runId = attempt.runId,
localEpochDay = attempt.localEpochDay,
)
val qualifyingRunIds = if (attempt.correct && !attempt.hinted) {
existing.unhintedCorrectRunIds + attempt.runId
} else {
existing.unhintedCorrectRunIds
}
val qualifyingDays = if (attempt.correct && !attempt.hinted) {
existing.unhintedCorrectEpochDays + attempt.localEpochDay
} else {
existing.unhintedCorrectEpochDays
}
val recent = (existing.recentOutcomes + outcome).takeLast(3)
val unhintedCorrectAnswers = existing.unhintedCorrectAnswers +
if (attempt.correct && !attempt.hinted) 1 else 0
val level = when {
attempt.hinted -> MasteryLevel.Learning
unhintedCorrectAnswers >= 4 &&
qualifyingDays.size >= 3 &&
recent.none { !it.correct && !it.hinted } -> MasteryLevel.Mastered
qualifyingRunIds.size >= 2 -> MasteryLevel.Familiar
else -> MasteryLevel.Learning
}
val reviewDelayDays = when {
!attempt.correct || attempt.hinted -> 1
level == MasteryLevel.Mastered -> 30
level == MasteryLevel.Familiar -> 7
else -> 1
}
return existing.copy(
level = level,
attempts = existing.attempts + 1,
correctAnswers = existing.correctAnswers + if (attempt.correct) 1 else 0,
unhintedCorrectAnswers = unhintedCorrectAnswers,
unhintedCorrectRunIds = qualifyingRunIds,
unhintedCorrectEpochDays = qualifyingDays,
recentOutcomes = recent,
nextReviewEpochDay = attempt.localEpochDay + reviewDelayDays,
)
}
fun applyAttemptToCountries(
mastery: List<Mastery>,
attempt: Attempt,
): List<Mastery> {
val byKey = mastery.associateBy { it.countryId to it.skill }.toMutableMap()
attempt.countryIds.forEach { countryId ->
val countryAttempt = attempt.copy(countryIds = listOf(countryId))
val key = countryId to attempt.skill
byKey[key] = applyAttempt(byKey[key], countryAttempt)
}
return byKey.values.sortedWith(compareBy(Mastery::countryId, { it.skill.ordinal }))
}
}
object StreakRules {
fun recordCompletedRun(current: Streak, completedEpochDay: Int): Streak {
val lastDay = current.lastCompletedEpochDay
if (lastDay != null && completedEpochDay <= lastDay) return current
val currentDays = if (lastDay != null && completedEpochDay == lastDay + 1) {
current.currentDays + 1
} else {
1
}
return Streak(
currentDays = currentDays,
bestDays = maxOf(current.bestDays, currentDays),
lastCompletedEpochDay = completedEpochDay,
)
}
}
data class PassportEvidence(
val completedRunCount: Int,
val recognizedFlagContinents: Set<Continent>,
val totalAnswered: Int,
val totalCorrect: Int,
val confusedFlagMastery: List<Mastery>,
val streak: Streak,
)
object PassportRules {
fun newlyEarned(
evidence: PassportEvidence,
existing: List<PassportStamp>,
earnedAtEpochMillis: Long,
): List<PassportStamp> {
val alreadyEarned = existing.mapTo(mutableSetOf()) { it.kind }
val earned = buildList {
if (evidence.completedRunCount >= 1) add(PassportStampKind.FirstCompletedRun)
if (evidence.recognizedFlagContinents.containsAll(PLAYED_CONTINENTS)) {
add(PassportStampKind.FlagsFromEveryContinent)
}
if (evidence.totalAnswered >= 50 && evidence.totalCorrect * 100 >= evidence.totalAnswered * 80) {
add(PassportStampKind.SustainedAccuracy)
}
if (evidence.confusedFlagMastery.isNotEmpty() &&
evidence.confusedFlagMastery.all { it.level == MasteryLevel.Mastered }
) {
add(PassportStampKind.ConfusedFlagsMastered)
}
if (evidence.streak.bestDays >= 7) add(PassportStampKind.SevenActiveDays)
}
return earned
.filterNot { it in alreadyEarned }
.map { PassportStamp(it, earnedAtEpochMillis) }
}
private val PLAYED_CONTINENTS = Continent.entries.filterNot { it == Continent.Antarctica }.toSet()
}
@@ -0,0 +1,98 @@
package app.bertsatlas.domain
import app.bertsatlas.domain.model.DifficultyTier
import app.bertsatlas.domain.model.Mastery
import app.bertsatlas.domain.model.MasteryLevel
import app.bertsatlas.domain.model.Question
import app.bertsatlas.domain.model.QuizTopic
import app.bertsatlas.domain.model.Skill
data class SelectedQuestion(
val question: Question,
val randomState: Long,
)
class QuestionSelector(
private val randomSource: RandomSource,
) {
fun select(
questions: List<Question>,
topic: QuizTopic,
tier: DifficultyTier,
mastery: List<Mastery>,
currentEpochDay: Int,
recentQuestionIds: List<String>,
randomState: Long,
): SelectedQuestion {
val topicQuestions = questions.filter { matchesTopic(it, topic) }
require(topicQuestions.isNotEmpty()) { "No questions are available for $topic" }
val withoutRecentFive = topicQuestions.filter { it.id !in recentQuestionIds.takeLast(5) }
val withoutImmediateRepeat = topicQuestions.filter { it.id != recentQuestionIds.lastOrNull() }
val candidates = when {
withoutRecentFive.isNotEmpty() -> withoutRecentFive
withoutImmediateRepeat.isNotEmpty() -> withoutImmediateRepeat
else -> topicQuestions
}
val weighted = candidates.flatMap { question ->
List(selectionWeight(question, topic, tier, mastery, currentEpochDay)) { question }
}
val random = randomSource.nextInt(randomState, weighted.size)
return SelectedQuestion(weighted[random.value], random.nextState)
}
private fun matchesTopic(question: Question, topic: QuizTopic): Boolean = when (topic) {
QuizTopic.MostlyFlags -> true
QuizTopic.Capitals -> question.skill == Skill.CapitalRecall ||
question.skill == Skill.CapitalRecognition
QuizTopic.MixedGeography -> true
}
private fun selectionWeight(
question: Question,
topic: QuizTopic,
tier: DifficultyTier,
mastery: List<Mastery>,
currentEpochDay: Int,
): Int {
val difficultyDistance = kotlin.math.abs(question.difficulty.ordinal - tier.ordinal)
val difficultyWeight = when (difficultyDistance) {
0 -> 4
1 -> 2
else -> 1
}
val records = mastery.filter { it.skill == question.skill && it.countryId in question.countryIds }
val reviewWeight = when {
records.any { it.nextReviewEpochDay != null && it.nextReviewEpochDay <= currentEpochDay } -> 8
records.any { it.level == MasteryLevel.Learning } -> 5
records.isEmpty() -> 4
records.all { it.level == MasteryLevel.Mastered } -> 1
else -> 2
}
val topicWeight = when (topic) {
QuizTopic.MostlyFlags -> when (question.skill) {
Skill.FlagRecognition -> 4
Skill.CountryToFlag -> 2
Skill.CapitalRecall,
Skill.CapitalRecognition,
-> 8
Skill.MixedGeography,
Skill.FlagSymbolism,
-> 8
}
QuizTopic.Capitals -> 1
QuizTopic.MixedGeography -> when (question.skill) {
Skill.FlagRecognition,
Skill.CountryToFlag,
-> 1
Skill.CapitalRecall,
Skill.CapitalRecognition,
-> 4
Skill.MixedGeography,
Skill.FlagSymbolism,
-> 6
}
}
return (difficultyWeight + reviewWeight) * topicWeight
}
}
@@ -0,0 +1,633 @@
package app.bertsatlas.domain
import app.bertsatlas.domain.model.ActiveQuestion
import app.bertsatlas.domain.model.ActiveRunSnapshot
import app.bertsatlas.domain.model.AdaptiveState
import app.bertsatlas.domain.model.AnswerFeedback
import app.bertsatlas.domain.model.Attempt
import app.bertsatlas.domain.model.Catalog
import app.bertsatlas.domain.model.Choice
import app.bertsatlas.domain.model.FeedbackKind
import app.bertsatlas.domain.model.FactRecord
import app.bertsatlas.domain.model.EditorialReviewStatus
import app.bertsatlas.domain.model.HintPresentation
import app.bertsatlas.domain.model.HintType
import app.bertsatlas.domain.model.LetterPlacement
import app.bertsatlas.domain.model.LetterTile
import app.bertsatlas.domain.model.Mastery
import app.bertsatlas.domain.model.Question
import app.bertsatlas.domain.model.QuizConfiguration
import app.bertsatlas.domain.model.QuizDuration
import app.bertsatlas.domain.model.QuizRunState
import app.bertsatlas.domain.model.RunPhase
import app.bertsatlas.domain.model.RunResult
import app.bertsatlas.domain.model.RunStatus
import app.bertsatlas.domain.model.SubmittedAnswer
class QuizEngine(
private val catalog: Catalog,
private val monotonicClock: MonotonicClock,
private val wallClock: WallClock,
private val localDateProvider: LocalDateProvider,
private val randomSource: RandomSource = SeededRandomSource,
private val allowUnapprovedContent: Boolean = false,
) {
private val questionSelector = QuestionSelector(randomSource)
fun startRun(
runId: String,
configuration: QuizConfiguration,
seed: Long,
mastery: List<Mastery>,
): QuizRunState {
require(runId.isNotBlank())
val usableSeed = if (seed == 0L) DEFAULT_SEED else seed
val selection = questionSelector.select(
questions = catalog.questions,
topic = configuration.topic,
tier = AdaptiveState().tier,
mastery = mastery,
currentEpochDay = localDateProvider.currentEpochDay(),
recentQuestionIds = emptyList(),
randomState = usableSeed,
)
val prepared = prepareQuestion(selection.question, selection.randomState)
val factGap = nextFactGap(prepared.randomState)
return QuizRunState(
runId = runId,
configuration = configuration,
seed = usableSeed,
randomState = factGap.randomState,
phase = RunPhase.Answering,
status = RunStatus.Active,
score = 0,
sessionRemainingMillis = configuration.duration.activeDurationMillis,
activeQuestion = prepared.activeQuestion,
nextFactAfterAnswerCount = factGap.gap,
tickingSinceMillis = monotonicClock.nowMillis(),
)
}
fun advanceClocks(state: QuizRunState): QuizRunState {
if (state.phase != RunPhase.Answering || state.tickingSinceMillis == null) return state
val now = monotonicClock.nowMillis()
val elapsed = (now - state.tickingSinceMillis).coerceAtLeast(0)
val sessionRemaining = state.sessionRemainingMillis?.let { (it - elapsed).coerceAtLeast(0) }
val sessionExpired = state.sessionExpired || sessionRemaining == 0L
return state.copy(
activeQuestion = state.activeQuestion.copy(
questionRemainingMillis = (state.activeQuestion.questionRemainingMillis - elapsed).coerceAtLeast(0),
questionElapsedMillis = state.activeQuestion.questionElapsedMillis + elapsed,
),
sessionRemainingMillis = sessionRemaining,
sessionExpired = sessionExpired,
runCompletionPending = state.runCompletionPending || sessionExpired,
tickingSinceMillis = now,
)
}
fun snapshotActiveRun(state: QuizRunState): ActiveRunSnapshot {
requireActive(state)
return ActiveRunSnapshot(
schemaVersion = ACTIVE_RUN_SNAPSHOT_SCHEMA_VERSION,
catalogVersion = catalog.contentVersion,
state = freezeClocks(state),
)
}
fun restoreRun(snapshot: ActiveRunSnapshot): QuizRunState {
require(snapshot.schemaVersion == ACTIVE_RUN_SNAPSHOT_SCHEMA_VERSION) {
"Unsupported active-run snapshot schema"
}
require(snapshot.state.status == RunStatus.Active) { "The snapshot does not contain an active run" }
val state = snapshot.state
require(state.phase != RunPhase.Completed) { "An active snapshot cannot be completed" }
require(state.score >= 0) { "A snapshot cannot contain a negative score" }
require(state.activeQuestion.questionRemainingMillis >= 0) { "A snapshot has an invalid question timer" }
require(state.sessionRemainingMillis == null || state.sessionRemainingMillis >= 0) {
"A snapshot has an invalid session timer"
}
if (state.phase == RunPhase.Paused) {
require(state.pausedFromPhase in PAUSABLE_PHASES) { "A paused snapshot has no resumable phase" }
return state.copy(tickingSinceMillis = null)
}
return state.copy(
phase = RunPhase.Paused,
pausedFromPhase = state.phase,
tickingSinceMillis = null,
)
}
fun answerQuestion(state: QuizRunState, answer: SubmittedAnswer): QuizRunState {
requireAnswering(state)
val timed = advanceClocks(state)
val evaluation = evaluateAnswer(timed.activeQuestion, answer)
val points = if (evaluation.correct) {
Scoring.pointsForCorrectAnswer(
timed.activeQuestion.questionRemainingMillis,
timed.activeQuestion.questionWindowMillis,
)
} else {
0
}
val hinted = timed.activeQuestion.hint != null
val overtime = evaluation.correct && timed.activeQuestion.questionRemainingMillis == 0L
val attempt = Attempt(
id = "${timed.runId}:${timed.attempts.size + 1}",
runId = timed.runId,
questionId = timed.activeQuestion.question.id,
countryIds = timed.activeQuestion.question.countryIds,
skill = timed.activeQuestion.question.skill,
correct = evaluation.correct,
hinted = hinted,
overtime = overtime,
submittedAnswer = evaluation.submittedText,
correctAnswer = correctAnswer(timed.activeQuestion.question),
responseMillis = timed.activeQuestion.questionElapsedMillis,
pointsAwarded = points,
answeredAtEpochMillis = wallClock.nowEpochMillis(),
localEpochDay = localDateProvider.currentEpochDay(),
hintType = timed.activeQuestion.hint?.type,
hintPointsDeducted = timed.activeQuestion.hintPointsDeducted,
questionWindowMillis = timed.activeQuestion.questionWindowMillis,
questionRemainingMillis = timed.activeQuestion.questionRemainingMillis,
)
val correctStreak = if (evaluation.correct) timed.currentCorrectStreak + 1 else 0
return timed.copy(
phase = RunPhase.Feedback,
score = timed.score + points,
answeredQuestionIds = timed.answeredQuestionIds + timed.activeQuestion.question.id,
recentQuestionIds = (timed.recentQuestionIds + timed.activeQuestion.question.id).takeLast(10),
answeredCountryIds = (timed.answeredCountryIds + timed.activeQuestion.question.countryIds).takeLast(10),
attempts = timed.attempts + attempt,
feedback = AnswerFeedback(
kind = when {
!evaluation.correct -> FeedbackKind.Incorrect
overtime -> FeedbackKind.OvertimeCorrect
else -> FeedbackKind.Correct
},
submittedAnswer = evaluation.submittedText,
correctAnswer = correctAnswer(timed.activeQuestion.question),
explanation = timed.activeQuestion.question.explanation,
pointsAwarded = points,
),
adaptive = AdaptiveDifficulty.afterAnswer(timed.adaptive, evaluation.correct, hinted),
currentCorrectStreak = correctStreak,
bestCorrectStreak = maxOf(timed.bestCorrectStreak, correctStreak),
tickingSinceMillis = null,
)
}
fun openHintChooser(state: QuizRunState): QuizRunState {
requireAnswering(state)
require(state.activeQuestion.hint == null) { "A hint has already been used for this question" }
require(state.activeQuestion.question.supportedHints.isNotEmpty()) { "This question does not offer hints" }
return freezeClocks(state).copy(phase = RunPhase.HintChooser)
}
fun chooseHint(state: QuizRunState, type: HintType): QuizRunState {
requireActive(state)
require(state.phase == RunPhase.HintChooser) { "Hints can only be chosen from the hint chooser" }
require(type in state.activeQuestion.question.supportedHints) { "This question does not support $type" }
require(state.activeQuestion.hint == null) { "A hint has already been used for this question" }
val scoreAfterHint = Scoring.deductHint(state.score)
val hintedQuestion = applyHint(state.activeQuestion, type).copy(
hintPointsDeducted = state.score - scoreAfterHint,
)
return state.copy(
phase = RunPhase.Answering,
score = scoreAfterHint,
activeQuestion = hintedQuestion,
tickingSinceMillis = monotonicClock.nowMillis(),
)
}
fun dismissFeedback(state: QuizRunState, mastery: List<Mastery>): QuizRunState {
requireActive(state)
require(state.phase == RunPhase.Feedback) { "Feedback is not visible" }
if (state.runAbandonmentPending) return abandonRun(state)
if (state.runCompletionPending || shouldShowScheduledFact(state)) {
return showRelatedFact(state, finalFact = state.runCompletionPending, mastery = mastery)
}
return moveToNextQuestion(state, mastery)
}
fun continueFromFact(state: QuizRunState, mastery: List<Mastery>): QuizRunState {
requireActive(state)
require(state.phase == RunPhase.FunFact) { "A fun fact is not visible" }
if (state.runAbandonmentPending) return abandonRun(state)
if (state.finalFactPending) {
return state.copy(
phase = RunPhase.Completed,
status = RunStatus.Completed,
visibleFact = null,
finalFactPending = false,
runCompletionPending = false,
runAbandonmentPending = false,
)
}
return moveToNextQuestion(state.copy(visibleFact = null), mastery)
}
fun pauseForInterruption(state: QuizRunState): QuizRunState {
requireActive(state)
if (state.phase == RunPhase.Paused) return state
val frozen = freezeClocks(state)
return frozen.copy(
phase = RunPhase.Paused,
pausedFromPhase = frozen.phase,
tickingSinceMillis = null,
)
}
fun resumeRun(state: QuizRunState): QuizRunState {
requireActive(state)
require(state.phase == RunPhase.Paused) { "Only a paused run can be resumed" }
val resumedPhase = requireNotNull(state.pausedFromPhase) { "A paused run has no resumable phase" }
require(resumedPhase in PAUSABLE_PHASES) { "The paused run has an invalid resumable phase" }
return state.copy(
phase = resumedPhase,
pausedFromPhase = null,
tickingSinceMillis = if (resumedPhase == RunPhase.Answering) monotonicClock.nowMillis() else null,
)
}
fun finishUnlimitedRun(state: QuizRunState, mastery: List<Mastery>): QuizRunState {
requireActive(state)
require(state.configuration.duration == QuizDuration.Unlimited) { "Only unlimited runs can be finished manually" }
val frozen = freezeClocks(state)
if (frozen.attempts.size < MINIMUM_UNLIMITED_ANSWERS) {
return when (frozen.phase) {
RunPhase.Feedback,
RunPhase.FunFact,
-> frozen.copy(runAbandonmentPending = true)
RunPhase.Paused -> when (frozen.pausedFromPhase) {
RunPhase.Feedback,
RunPhase.FunFact,
-> frozen.copy(runAbandonmentPending = true)
else -> abandonRun(frozen)
}
else -> abandonRun(frozen)
}
}
return when (frozen.phase) {
RunPhase.Feedback -> frozen.copy(runCompletionPending = true)
RunPhase.FunFact -> frozen.copy(
runCompletionPending = true,
finalFactPending = true,
)
RunPhase.Paused -> when (frozen.pausedFromPhase) {
RunPhase.Feedback -> frozen.copy(runCompletionPending = true)
RunPhase.FunFact -> frozen.copy(
runCompletionPending = true,
finalFactPending = true,
)
else -> showRelatedFact(frozen, finalFact = true, mastery = mastery)
}
RunPhase.Answering,
RunPhase.HintChooser,
-> showRelatedFact(frozen, finalFact = true, mastery = mastery)
RunPhase.Completed -> error("An active run cannot already be completed")
}
}
fun abandonRun(state: QuizRunState): QuizRunState {
requireActive(state)
return freezeClocks(state).copy(
phase = RunPhase.Completed,
status = RunStatus.Abandoned,
feedback = null,
visibleFact = null,
finalFactPending = false,
runCompletionPending = false,
runAbandonmentPending = false,
tickingSinceMillis = null,
)
}
fun placeLetter(state: QuizRunState, tileId: String): QuizRunState {
requireAnswering(state)
val timed = advanceClocks(state)
val active = timed.activeQuestion
val question = active.question as? Question.LetterBank ?: error("The active question is not a letter bank")
require(active.letterTiles.any { it.id == tileId }) { "Unknown letter tile" }
require(tileId !in active.removedTileIds) { "This letter tile has been removed" }
require(active.placements.none { it.tileId == tileId }) { "This letter tile is already placed" }
val answerLength = canonicalLetters(question.displayAnswer).length
val availableSlot = (0 until answerLength).firstOrNull { slot ->
active.placements.none { it.slotIndex == slot }
} ?: error("Every answer slot is filled")
return timed.copy(
activeQuestion = active.copy(
placements = (active.placements + LetterPlacement(availableSlot, tileId)).sortedBy { it.slotIndex },
),
)
}
fun removeLetter(state: QuizRunState, slotIndex: Int): QuizRunState {
requireAnswering(state)
val timed = advanceClocks(state)
require(timed.activeQuestion.question is Question.LetterBank) { "The active question is not a letter bank" }
require(timed.activeQuestion.placements.any { it.slotIndex == slotIndex }) { "The answer slot is empty" }
return timed.copy(
activeQuestion = timed.activeQuestion.copy(
placements = timed.activeQuestion.placements.filterNot { it.slotIndex == slotIndex },
),
)
}
fun clearLetters(state: QuizRunState): QuizRunState {
requireAnswering(state)
val timed = advanceClocks(state)
require(timed.activeQuestion.question is Question.LetterBank) { "The active question is not a letter bank" }
return timed.copy(activeQuestion = timed.activeQuestion.copy(placements = emptyList()))
}
fun submitLetters(state: QuizRunState): QuizRunState {
requireAnswering(state)
val question = state.activeQuestion.question as? Question.LetterBank
?: error("The active question is not a letter bank")
val answerLength = canonicalLetters(question.displayAnswer).length
require(state.activeQuestion.placements.size == answerLength) { "Every answer slot must be filled before submitting" }
val tileIds = state.activeQuestion.placements.sortedBy { it.slotIndex }.map { it.tileId }
return answerQuestion(state, SubmittedAnswer.LetterAnswer(tileIds))
}
fun runResult(state: QuizRunState, improvedMasteryItems: Int): RunResult {
require(state.status == RunStatus.Completed) { "Only a completed run has results" }
val correct = state.attempts.count { it.correct }
val averageResponse = if (state.attempts.isEmpty()) 0 else {
state.attempts.sumOf { it.responseMillis } / state.attempts.size
}
return RunResult(
runId = state.runId,
configuration = state.configuration,
score = state.score,
correctAnswers = correct,
answeredQuestions = state.attempts.size,
averageResponseMillis = averageResponse,
bestCorrectStreak = state.bestCorrectStreak,
improvedMasteryItems = improvedMasteryItems,
revisitCountryIds = state.attempts
.filter { !it.correct || it.hinted }
.flatMap { it.countryIds }
.distinct(),
completedAtEpochMillis = wallClock.nowEpochMillis(),
)
}
private fun moveToNextQuestion(state: QuizRunState, mastery: List<Mastery>): QuizRunState {
val selection = questionSelector.select(
questions = catalog.questions,
topic = state.configuration.topic,
tier = state.adaptive.tier,
mastery = mastery,
currentEpochDay = localDateProvider.currentEpochDay(),
recentQuestionIds = state.recentQuestionIds,
randomState = state.randomState,
)
val prepared = prepareQuestion(selection.question, selection.randomState)
return state.copy(
randomState = prepared.randomState,
phase = RunPhase.Answering,
activeQuestion = prepared.activeQuestion,
feedback = null,
visibleFact = null,
tickingSinceMillis = monotonicClock.nowMillis(),
)
}
private fun showRelatedFact(
state: QuizRunState,
finalFact: Boolean,
mastery: List<Mastery>,
): QuizRunState {
val selectedFact = selectFact(state)
if (selectedFact == null) {
if (finalFact) {
return state.copy(
phase = RunPhase.Completed,
status = RunStatus.Completed,
feedback = null,
runCompletionPending = false,
)
}
return moveToNextQuestion(state, mastery)
}
val nextGap = nextFactGap(selectedFact.randomState)
return state.copy(
randomState = nextGap.randomState,
phase = RunPhase.FunFact,
feedback = null,
visibleFact = selectedFact.fact,
recentFactIds = (state.recentFactIds + selectedFact.fact.id).takeLast(10),
nextFactAfterAnswerCount = state.attempts.size + nextGap.gap,
finalFactPending = finalFact,
tickingSinceMillis = null,
)
}
private fun selectFact(state: QuizRunState): SelectedFact? {
val approved = catalog.facts.filter {
it.reviewStatus == EditorialReviewStatus.Approved || allowUnapprovedContent
}
val related = approved.filter { fact -> fact.countryIds.any { it in state.answeredCountryIds } }
val relatedNotRecent = related.filter { it.id !in state.recentFactIds }
val candidates = when {
relatedNotRecent.isNotEmpty() -> relatedNotRecent
related.isNotEmpty() -> related
else -> return null
}
val random = randomSource.nextInt(state.randomState, candidates.size)
return SelectedFact(candidates[random.value], random.nextState)
}
private fun shouldShowScheduledFact(state: QuizRunState): Boolean =
state.attempts.size >= state.nextFactAfterAnswerCount
private fun freezeClocks(state: QuizRunState): QuizRunState =
advanceClocks(state).copy(tickingSinceMillis = null)
private fun applyHint(active: ActiveQuestion, type: HintType): ActiveQuestion {
val question = active.question
return when (type) {
HintType.RevealCorrectLetter -> revealCorrectLetter(active)
HintType.RemoveIncorrectOptions -> when (question) {
is Question.MultipleChoice -> {
val removed = active.orderedChoices
.filter { it.id != question.correctChoiceId }
.take(2)
.mapTo(linkedSetOf()) { it.id }
active.copy(
hint = HintPresentation(type = type, removedChoiceIds = removed),
removedChoiceIds = removed,
)
}
is Question.LetterBank -> {
val removed = active.letterTiles
.filter { it.isDecoy }
.mapTo(linkedSetOf()) { it.id }
active.copy(
hint = HintPresentation(type = type, removedTileIds = removed),
removedTileIds = removed,
placements = active.placements.filterNot { it.tileId in removed },
)
}
}
HintType.RevealContinent,
HintType.FunnyFactualClue,
-> {
val message = question.hintText[type]
require(!message.isNullOrBlank()) { "$type requires hint text" }
active.copy(hint = HintPresentation(type = type, message = message))
}
}
}
private fun revealCorrectLetter(active: ActiveQuestion): ActiveQuestion {
val question = active.question as? Question.LetterBank
?: error("Reveal correct letter requires a letter-bank question")
val required = canonicalLetters(question.displayAnswer)
val tilesById = active.letterTiles.associateBy { it.id }
val slot = required.indices.firstOrNull { index ->
val placement = active.placements.firstOrNull { it.slotIndex == index }
placement == null || tilesById[placement.tileId]?.character != required[index]
} ?: 0
val requiredTileId = "${question.id}:required:$slot"
val placements = active.placements
.filterNot { it.slotIndex == slot || it.tileId == requiredTileId }
.plus(LetterPlacement(slot, requiredTileId))
.sortedBy { it.slotIndex }
return active.copy(
placements = placements,
hint = HintPresentation(
type = HintType.RevealCorrectLetter,
message = required[slot].toString(),
revealedTileId = requiredTileId,
),
)
}
private fun evaluateAnswer(active: ActiveQuestion, answer: SubmittedAnswer): AnswerEvaluation =
when (val question = active.question) {
is Question.MultipleChoice -> {
val choiceAnswer = answer as? SubmittedAnswer.ChoiceAnswer
?: error("A multiple-choice answer is required")
val choice = active.orderedChoices.firstOrNull { it.id == choiceAnswer.choiceId }
?: error("Unknown answer choice")
require(choice.id !in active.removedChoiceIds) { "This answer choice has been removed" }
AnswerEvaluation(choice.id == question.correctChoiceId, choice.label)
}
is Question.LetterBank -> {
val letterAnswer = answer as? SubmittedAnswer.LetterAnswer
?: error("A letter-bank answer is required")
val placedTileIds = active.placements.sortedBy { it.slotIndex }.map { it.tileId }
require(letterAnswer.tileIds == placedTileIds) {
"The submitted letters must match the placed answer"
}
val tiles = active.letterTiles.associateBy { it.id }
require(letterAnswer.tileIds.distinct().size == letterAnswer.tileIds.size) {
"Each letter tile can only be used once"
}
require(letterAnswer.tileIds.none { it in active.removedTileIds }) { "A removed tile was submitted" }
val submitted = letterAnswer.tileIds.map { id ->
tiles[id]?.character ?: error("Unknown letter tile")
}.joinToString("")
val expected = canonicalLetters(question.displayAnswer)
require(submitted.length == expected.length) { "Every answer slot must be filled before submitting" }
AnswerEvaluation(submitted.equals(expected, ignoreCase = true), submitted)
}
}
private fun correctAnswer(question: Question): String = when (question) {
is Question.MultipleChoice -> question.choices.first { it.id == question.correctChoiceId }.label
is Question.LetterBank -> question.displayAnswer
}
private fun prepareQuestion(question: Question, randomState: Long): PreparedQuestion = when (question) {
is Question.MultipleChoice -> {
require(question.choices.size == 4) { "Multiple-choice questions require four choices" }
require(question.choices.map { it.id }.distinct().size == question.choices.size) {
"Multiple-choice question IDs must be unique"
}
require(question.choices.map { it.label }.distinct().size == question.choices.size) {
"Multiple-choice labels must be unique"
}
require(question.choices.any { it.id == question.correctChoiceId }) { "Correct choice is missing" }
val shuffled = question.choices.shuffled(randomSource, randomState)
PreparedQuestion(
activeQuestion = ActiveQuestion(
question = question,
orderedChoices = shuffled.first,
questionWindowMillis = MULTIPLE_CHOICE_WINDOW_MILLIS,
questionRemainingMillis = MULTIPLE_CHOICE_WINDOW_MILLIS,
),
randomState = shuffled.second,
)
}
is Question.LetterBank -> {
require(canonicalLetters(question.displayAnswer).isNotEmpty()) {
"Letter-bank questions require a playable display answer"
}
require(question.decoyLetters.size == LETTER_BANK_DECOY_COUNT) {
"Letter-bank questions require exactly three decoy letters"
}
val requiredTiles = canonicalLetters(question.displayAnswer).mapIndexed { index, character ->
LetterTile("${question.id}:required:$index", character, isDecoy = false)
}
val decoyTiles = question.decoyLetters.mapIndexed { index, character ->
LetterTile("${question.id}:decoy:$index", character.uppercaseChar(), isDecoy = true)
}
val shuffled = (requiredTiles + decoyTiles).shuffled(randomSource, randomState)
PreparedQuestion(
activeQuestion = ActiveQuestion(
question = question,
letterTiles = shuffled.first,
questionWindowMillis = LETTER_BANK_WINDOW_MILLIS,
questionRemainingMillis = LETTER_BANK_WINDOW_MILLIS,
),
randomState = shuffled.second,
)
}
}
private fun nextFactGap(randomState: Long): FactGap {
val random = randomSource.nextInt(randomState, MAX_FACT_GAP - MIN_FACT_GAP + 1)
return FactGap(MIN_FACT_GAP + random.value, random.nextState)
}
private fun canonicalLetters(answer: String): String =
answer.filter { it.isLetter() }.uppercase()
private fun requireAnswering(state: QuizRunState) {
requireActive(state)
require(state.phase == RunPhase.Answering) { "The run is not accepting answers" }
}
private fun requireActive(state: QuizRunState) {
require(state.status == RunStatus.Active) { "The run is no longer active" }
}
private data class PreparedQuestion(val activeQuestion: ActiveQuestion, val randomState: Long)
private data class SelectedFact(val fact: FactRecord, val randomState: Long)
private data class FactGap(val gap: Int, val randomState: Long)
private data class AnswerEvaluation(val correct: Boolean, val submittedText: String)
companion object {
const val MULTIPLE_CHOICE_WINDOW_MILLIS = 10_000L
const val LETTER_BANK_WINDOW_MILLIS = 20_000L
const val LETTER_BANK_DECOY_COUNT = 3
const val MINIMUM_UNLIMITED_ANSWERS = 5
const val ACTIVE_RUN_SNAPSHOT_SCHEMA_VERSION = 1
private const val MIN_FACT_GAP = 5
private const val MAX_FACT_GAP = 8
private const val DEFAULT_SEED = -7046029254386353131L
private val PAUSABLE_PHASES = setOf(
RunPhase.Answering,
RunPhase.HintChooser,
RunPhase.Feedback,
RunPhase.FunFact,
)
}
}
@@ -0,0 +1,42 @@
package app.bertsatlas.domain
data class RandomValue(
val value: Int,
val nextState: Long,
)
fun interface RandomSource {
fun nextInt(state: Long, bound: Int): RandomValue
}
object SeededRandomSource : RandomSource {
override fun nextInt(state: Long, bound: Int): RandomValue {
require(bound > 0)
var next = state
next = next xor (next shl 13)
next = next xor (next ushr 7)
next = next xor (next shl 17)
if (next == 0L) next = NON_ZERO_FALLBACK
val positive = next ushr 1
return RandomValue((positive % bound.toLong()).toInt(), next)
}
private const val NON_ZERO_FALLBACK = -7046029254386353131L
}
internal fun <T> List<T>.shuffled(
randomSource: RandomSource,
initialState: Long,
): Pair<List<T>, Long> {
val result = toMutableList()
var state = initialState
for (index in result.lastIndex downTo 1) {
val random = randomSource.nextInt(state, index + 1)
state = random.nextState
val other = random.value
val item = result[index]
result[index] = result[other]
result[other] = item
}
return result to state
}
@@ -0,0 +1,14 @@
package app.bertsatlas.domain
object Scoring {
const val HINT_COST = 25
fun pointsForCorrectAnswer(remainingMillis: Long, responseWindowMillis: Long): Int {
require(responseWindowMillis > 0)
if (remainingMillis <= 0) return 50
val speedPoints = (50L * remainingMillis / responseWindowMillis).toInt()
return (50 + speedPoints).coerceAtMost(100)
}
fun deductHint(score: Int): Int = (score - HINT_COST).coerceAtLeast(0)
}
@@ -0,0 +1,13 @@
package app.bertsatlas.domain
fun interface MonotonicClock {
fun nowMillis(): Long
}
fun interface WallClock {
fun nowEpochMillis(): Long
}
fun interface LocalDateProvider {
fun currentEpochDay(): Int
}
@@ -0,0 +1,91 @@
package app.bertsatlas.domain.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Catalog(
val schemaVersion: Int,
val contentVersion: String,
val countries: List<CountryRecord>,
val questions: List<Question>,
val facts: List<FactRecord>,
val sources: List<SourceRecord>,
val assetLicenses: List<AssetLicenseRecord>,
)
@Serializable
data class CountryRecord(
val id: String,
val displayName: String,
val acceptedAliases: List<String> = emptyList(),
val continentIds: List<Continent>,
val flagAssetId: String,
val capitalArrangement: CapitalArrangement,
val similarityGroupIds: List<String> = emptyList(),
)
@Serializable
sealed interface CapitalArrangement {
@Serializable
@SerialName("single")
data class Single(val capital: String) : CapitalArrangement
@Serializable
@SerialName("multiple")
data class Multiple(
val capitals: List<String>,
val explanation: String,
) : CapitalArrangement
@Serializable
@SerialName("exception")
data class Exception(val explanation: String) : CapitalArrangement
}
@Serializable
data class FactRecord(
val id: String,
val heading: String,
val body: String,
val countryIds: List<String>,
val sourceIds: List<String>,
val asOf: String? = null,
val assetId: String? = null,
val reviewStatus: EditorialReviewStatus,
)
@Serializable
enum class EditorialReviewStatus {
Draft,
FactChecked,
CopyChecked,
Approved,
}
@Serializable
data class SourceRecord(
val id: String,
val title: String,
val url: String,
val revision: String? = null,
val accessedOn: String? = null,
)
@Serializable
data class AssetLicenseRecord(
val assetId: String,
val sourceId: String? = null,
val licenseName: String,
val licenseUrl: String? = null,
val attribution: String? = null,
val modified: Boolean,
val checksum: String,
)
@Serializable
data class CuratedQuestion(
val question: Question,
val sourceIds: List<String>,
val requiredAssetIds: List<String> = emptyList(),
)
@@ -0,0 +1,52 @@
package app.bertsatlas.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class QuizConfiguration(
val duration: QuizDuration = QuizDuration.FiveMinutes,
val topic: QuizTopic = QuizTopic.MostlyFlags,
)
@Serializable
enum class QuizDuration(val activeDurationMillis: Long?) {
FiveMinutes(5 * 60 * 1_000L),
TenMinutes(10 * 60 * 1_000L),
TwentyMinutes(20 * 60 * 1_000L),
Unlimited(null),
}
@Serializable
enum class QuizTopic {
MostlyFlags,
Capitals,
MixedGeography,
}
@Serializable
enum class DifficultyTier {
Casual,
Tricky,
Brutal,
}
@Serializable
enum class Skill {
FlagRecognition,
CountryToFlag,
CapitalRecall,
CapitalRecognition,
MixedGeography,
FlagSymbolism,
}
@Serializable
enum class Continent {
Africa,
Asia,
Europe,
NorthAmerica,
SouthAmerica,
Oceania,
Antarctica,
}
@@ -0,0 +1,106 @@
package app.bertsatlas.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class Attempt(
val id: String,
val runId: String,
val questionId: String,
val countryIds: List<String>,
val skill: Skill,
val correct: Boolean,
val hinted: Boolean,
val overtime: Boolean,
val submittedAnswer: String,
val correctAnswer: String,
val responseMillis: Long,
val pointsAwarded: Int,
val answeredAtEpochMillis: Long,
val localEpochDay: Int,
val hintType: HintType? = null,
val hintPointsDeducted: Int = 0,
val questionWindowMillis: Long = 0,
val questionRemainingMillis: Long = 0,
)
@Serializable
enum class MasteryLevel {
New,
Learning,
Familiar,
Mastered,
}
@Serializable
data class MasteryOutcome(
val correct: Boolean,
val hinted: Boolean,
val runId: String,
val localEpochDay: Int,
)
@Serializable
data class Mastery(
val countryId: String,
val skill: Skill,
val level: MasteryLevel = MasteryLevel.New,
val attempts: Int = 0,
val correctAnswers: Int = 0,
val unhintedCorrectAnswers: Int = 0,
val unhintedCorrectRunIds: Set<String> = emptySet(),
val unhintedCorrectEpochDays: Set<Int> = emptySet(),
val recentOutcomes: List<MasteryOutcome> = emptyList(),
val nextReviewEpochDay: Int? = null,
)
@Serializable
data class Streak(
val currentDays: Int = 0,
val bestDays: Int = 0,
val lastCompletedEpochDay: Int? = null,
)
@Serializable
enum class PassportStampKind {
FirstCompletedRun,
FlagsFromEveryContinent,
SustainedAccuracy,
ConfusedFlagsMastered,
SevenActiveDays,
}
@Serializable
data class PassportStamp(
val kind: PassportStampKind,
val earnedAtEpochMillis: Long,
)
@Serializable
data class RunResult(
val runId: String,
val configuration: QuizConfiguration,
val score: Int,
val correctAnswers: Int,
val answeredQuestions: Int,
val averageResponseMillis: Long,
val bestCorrectStreak: Int,
val improvedMasteryItems: Int,
val revisitCountryIds: List<String>,
val completedAtEpochMillis: Long,
) {
val accuracyPercent: Int
get() = if (answeredQuestions == 0) 0 else (correctAnswers * 100) / answeredQuestions
}
@Serializable
data class ProgressSummary(
val totalAnswered: Int,
val totalCorrect: Int,
val mastery: List<Mastery>,
val streak: Streak,
val stamps: List<PassportStamp>,
) {
val accuracyPercent: Int
get() = if (totalAnswered == 0) 0 else (totalCorrect * 100) / totalAnswered
}
@@ -0,0 +1,100 @@
package app.bertsatlas.domain.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed interface Question {
val id: String
val prompt: String
val countryIds: List<String>
val skill: Skill
val topic: QuizTopic
val difficulty: DifficultyTier
val explanation: String?
val supportedHints: Set<HintType>
val hintText: Map<HintType, String>
@Serializable
@SerialName("multiple_choice")
data class MultipleChoice(
override val id: String,
override val prompt: String,
override val countryIds: List<String>,
override val skill: Skill,
override val topic: QuizTopic,
override val difficulty: DifficultyTier,
override val explanation: String? = null,
override val supportedHints: Set<HintType>,
override val hintText: Map<HintType, String> = emptyMap(),
val choices: List<Choice>,
val correctChoiceId: String,
val promptAssetId: String? = null,
) : Question
@Serializable
@SerialName("letter_bank")
data class LetterBank(
override val id: String,
override val prompt: String,
override val countryIds: List<String>,
override val skill: Skill,
override val topic: QuizTopic,
override val difficulty: DifficultyTier,
override val explanation: String? = null,
override val supportedHints: Set<HintType>,
override val hintText: Map<HintType, String> = emptyMap(),
val displayAnswer: String,
val acceptedAliases: List<String> = emptyList(),
val promptAssetId: String,
val decoyLetters: List<Char>,
) : Question
}
@Serializable
data class Choice(
val id: String,
val label: String,
val assetId: String? = null,
)
@Serializable
enum class HintType {
RevealCorrectLetter,
RemoveIncorrectOptions,
RevealContinent,
FunnyFactualClue,
}
@Serializable
data class HintPresentation(
val type: HintType,
val message: String? = null,
val revealedTileId: String? = null,
val removedChoiceIds: Set<String> = emptySet(),
val removedTileIds: Set<String> = emptySet(),
)
@Serializable
data class LetterTile(
val id: String,
val character: Char,
val isDecoy: Boolean,
)
@Serializable
data class LetterPlacement(
val slotIndex: Int,
val tileId: String,
)
@Serializable
sealed interface SubmittedAnswer {
@Serializable
@SerialName("choice")
data class ChoiceAnswer(val choiceId: String) : SubmittedAnswer
@Serializable
@SerialName("letters")
data class LetterAnswer(val tileIds: List<String>) : SubmittedAnswer
}
@@ -0,0 +1,95 @@
package app.bertsatlas.domain.model
import kotlinx.serialization.Serializable
@Serializable
enum class RunPhase {
Answering,
HintChooser,
Feedback,
FunFact,
Paused,
Completed,
}
@Serializable
enum class RunStatus {
Active,
Completed,
Abandoned,
}
@Serializable
enum class FeedbackKind {
Correct,
Incorrect,
OvertimeCorrect,
}
@Serializable
data class AnswerFeedback(
val kind: FeedbackKind,
val submittedAnswer: String,
val correctAnswer: String,
val explanation: String?,
val pointsAwarded: Int,
)
@Serializable
data class AdaptiveState(
val tier: DifficultyTier = DifficultyTier.Casual,
val consecutiveUnhintedCorrect: Int = 0,
val consecutiveIncorrect: Int = 0,
)
@Serializable
data class ActiveQuestion(
val question: Question,
val orderedChoices: List<Choice> = emptyList(),
val letterTiles: List<LetterTile> = emptyList(),
val placements: List<LetterPlacement> = emptyList(),
val questionWindowMillis: Long,
val questionRemainingMillis: Long,
val questionElapsedMillis: Long = 0,
val hint: HintPresentation? = null,
val hintPointsDeducted: Int = 0,
val removedChoiceIds: Set<String> = emptySet(),
val removedTileIds: Set<String> = emptySet(),
)
@Serializable
data class QuizRunState(
val runId: String,
val configuration: QuizConfiguration,
val seed: Long,
val randomState: Long,
val phase: RunPhase,
val status: RunStatus,
val score: Int,
val sessionRemainingMillis: Long?,
val activeQuestion: ActiveQuestion,
val answeredQuestionIds: List<String> = emptyList(),
val recentQuestionIds: List<String> = emptyList(),
val recentFactIds: List<String> = emptyList(),
val answeredCountryIds: List<String> = emptyList(),
val attempts: List<Attempt> = emptyList(),
val feedback: AnswerFeedback? = null,
val visibleFact: FactRecord? = null,
val adaptive: AdaptiveState = AdaptiveState(),
val currentCorrectStreak: Int = 0,
val bestCorrectStreak: Int = 0,
val nextFactAfterAnswerCount: Int,
val sessionExpired: Boolean = false,
val runCompletionPending: Boolean = false,
val runAbandonmentPending: Boolean = false,
val finalFactPending: Boolean = false,
val tickingSinceMillis: Long? = null,
val pausedFromPhase: RunPhase? = null,
)
@Serializable
data class ActiveRunSnapshot(
val schemaVersion: Int,
val catalogVersion: String,
val state: QuizRunState,
)