From b0b57b8535f169cbc6188cd2d4cbb88477713502 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 9 Sep 2026 23:44:40 +0530 Subject: [PATCH] implement quiz domain engine --- .../bertsatlas/domain/AdaptiveDifficulty.kt | 51 ++ .../kotlin/app/bertsatlas/domain/Contracts.kt | 86 +++ .../app/bertsatlas/domain/ProgressRules.kt | 133 ++++ .../app/bertsatlas/domain/QuestionSelector.kt | 98 +++ .../app/bertsatlas/domain/QuizEngine.kt | 633 ++++++++++++++++++ .../app/bertsatlas/domain/RandomSource.kt | 42 ++ .../kotlin/app/bertsatlas/domain/Scoring.kt | 14 + .../kotlin/app/bertsatlas/domain/Time.kt | 13 + .../app/bertsatlas/domain/model/Catalog.kt | 91 +++ .../bertsatlas/domain/model/Configuration.kt | 52 ++ .../app/bertsatlas/domain/model/Progress.kt | 106 +++ .../app/bertsatlas/domain/model/Question.kt | 100 +++ .../kotlin/app/bertsatlas/domain/model/Run.kt | 95 +++ 13 files changed, 1514 insertions(+) create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/AdaptiveDifficulty.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/Contracts.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/ProgressRules.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/QuestionSelector.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/QuizEngine.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/RandomSource.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/Scoring.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/Time.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Catalog.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Configuration.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Progress.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Question.kt create mode 100644 domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Run.kt diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/AdaptiveDifficulty.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/AdaptiveDifficulty.kt new file mode 100644 index 0000000..4bcf64f --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/AdaptiveDifficulty.kt @@ -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 + } +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/Contracts.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/Contracts.kt new file mode 100644 index 0000000..3707f46 --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/Contracts.kt @@ -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 + fun observeProgress(): Flow +} + +interface ProgressWriter { + suspend fun recordAcceptedAnswer( + attempt: Attempt, + updatedMastery: List, + activeRunSnapshot: ActiveRunSnapshot, + ) + + suspend fun recordCompletedRun( + result: RunResult, + streak: Streak, + newlyEarnedStamps: List, + ) + + 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 + 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 +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/ProgressRules.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/ProgressRules.kt new file mode 100644 index 0000000..bf13d9c --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/ProgressRules.kt @@ -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, + attempt: Attempt, + ): List { + 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, + val totalAnswered: Int, + val totalCorrect: Int, + val confusedFlagMastery: List, + val streak: Streak, +) + +object PassportRules { + fun newlyEarned( + evidence: PassportEvidence, + existing: List, + earnedAtEpochMillis: Long, + ): List { + 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() +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/QuestionSelector.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/QuestionSelector.kt new file mode 100644 index 0000000..145c6eb --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/QuestionSelector.kt @@ -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, + topic: QuizTopic, + tier: DifficultyTier, + mastery: List, + currentEpochDay: Int, + recentQuestionIds: List, + 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, + 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 + } +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/QuizEngine.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/QuizEngine.kt new file mode 100644 index 0000000..b093f80 --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/QuizEngine.kt @@ -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, + ): 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): 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): 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): 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): 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, + ): 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, + ) + } +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/RandomSource.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/RandomSource.kt new file mode 100644 index 0000000..3cf82a7 --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/RandomSource.kt @@ -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 List.shuffled( + randomSource: RandomSource, + initialState: Long, +): Pair, 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 +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/Scoring.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/Scoring.kt new file mode 100644 index 0000000..f200450 --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/Scoring.kt @@ -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) +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/Time.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/Time.kt new file mode 100644 index 0000000..dea86be --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/Time.kt @@ -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 +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Catalog.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Catalog.kt new file mode 100644 index 0000000..c8ec17c --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Catalog.kt @@ -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, + val questions: List, + val facts: List, + val sources: List, + val assetLicenses: List, +) + +@Serializable +data class CountryRecord( + val id: String, + val displayName: String, + val acceptedAliases: List = emptyList(), + val continentIds: List, + val flagAssetId: String, + val capitalArrangement: CapitalArrangement, + val similarityGroupIds: List = emptyList(), +) + +@Serializable +sealed interface CapitalArrangement { + @Serializable + @SerialName("single") + data class Single(val capital: String) : CapitalArrangement + + @Serializable + @SerialName("multiple") + data class Multiple( + val capitals: List, + 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, + val sourceIds: List, + 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, + val requiredAssetIds: List = emptyList(), +) diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Configuration.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Configuration.kt new file mode 100644 index 0000000..57d8732 --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Configuration.kt @@ -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, +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Progress.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Progress.kt new file mode 100644 index 0000000..93ae206 --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Progress.kt @@ -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, + 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 = emptySet(), + val unhintedCorrectEpochDays: Set = emptySet(), + val recentOutcomes: List = 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, + 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, + val streak: Streak, + val stamps: List, +) { + val accuracyPercent: Int + get() = if (totalAnswered == 0) 0 else (totalCorrect * 100) / totalAnswered +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Question.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Question.kt new file mode 100644 index 0000000..4a1057d --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Question.kt @@ -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 + val skill: Skill + val topic: QuizTopic + val difficulty: DifficultyTier + val explanation: String? + val supportedHints: Set + val hintText: Map + + @Serializable + @SerialName("multiple_choice") + data class MultipleChoice( + override val id: String, + override val prompt: String, + override val countryIds: List, + override val skill: Skill, + override val topic: QuizTopic, + override val difficulty: DifficultyTier, + override val explanation: String? = null, + override val supportedHints: Set, + override val hintText: Map = emptyMap(), + val choices: List, + 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, + override val skill: Skill, + override val topic: QuizTopic, + override val difficulty: DifficultyTier, + override val explanation: String? = null, + override val supportedHints: Set, + override val hintText: Map = emptyMap(), + val displayAnswer: String, + val acceptedAliases: List = emptyList(), + val promptAssetId: String, + val decoyLetters: List, + ) : 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 = emptySet(), + val removedTileIds: Set = 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) : SubmittedAnswer +} diff --git a/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Run.kt b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Run.kt new file mode 100644 index 0000000..b8e0f68 --- /dev/null +++ b/domain/src/commonMain/kotlin/app/bertsatlas/domain/model/Run.kt @@ -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 = emptyList(), + val letterTiles: List = emptyList(), + val placements: List = emptyList(), + val questionWindowMillis: Long, + val questionRemainingMillis: Long, + val questionElapsedMillis: Long = 0, + val hint: HintPresentation? = null, + val hintPointsDeducted: Int = 0, + val removedChoiceIds: Set = emptySet(), + val removedTileIds: Set = 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 = emptyList(), + val recentQuestionIds: List = emptyList(), + val recentFactIds: List = emptyList(), + val answeredCountryIds: List = emptyList(), + val attempts: List = 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, +)