mirror of
https://github.com/priyanshujain/bertsatlas.git
synced 2026-10-02 19:06:59 +00:00
implement quiz domain engine
This commit is contained in:
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,
|
||||||
|
)
|
||||||
Reference in new issue
Block a user