mirror of
https://github.com/priyanshujain/bertsatlas.git
synced 2026-10-02 11: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