fix android trivia experience

This commit is contained in:
pj committed 2026-09-10 02:57:55 +05:30
1 parent 86450bef44
commit c6b2eb3cce
69 files changed
+3493 -1572

No files matched your search

@@ -55,8 +55,8 @@ data class PlayerSettings(
)
interface LaunchPreferences {
suspend fun hasLaunchedBefore(): Boolean
suspend fun markLaunched()
suspend fun hasStartedTrivia(): Boolean
suspend fun markTriviaStarted()
}
interface QuizConfigurationPreferences {
@@ -27,10 +27,22 @@ class QuestionSelector(
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 priorQuestionIds = recentQuestionIds.takeLast(RECENT_QUESTION_WINDOW).toSet()
val priorQuestions = questions.filter { it.id in priorQuestionIds }
val immediateQuestion = questions.firstOrNull { it.id == recentQuestionIds.lastOrNull() }
val withoutRecentConcepts = topicQuestions.filter { candidate ->
candidate.id !in priorQuestionIds && priorQuestions.none { recent -> candidate.repeatsConceptFrom(recent) }
}
val withoutImmediateConcept = topicQuestions.filter { candidate ->
candidate.id != immediateQuestion?.id &&
(immediateQuestion == null || !candidate.repeatsConceptFrom(immediateQuestion))
}
val withoutPriorQuestions = topicQuestions.filter { it.id !in priorQuestionIds }
val withoutImmediateRepeat = topicQuestions.filter { it.id != immediateQuestion?.id }
val candidates = when {
withoutRecentFive.isNotEmpty() -> withoutRecentFive
withoutRecentConcepts.isNotEmpty() -> withoutRecentConcepts
withoutImmediateConcept.isNotEmpty() -> withoutImmediateConcept
withoutPriorQuestions.isNotEmpty() -> withoutPriorQuestions
withoutImmediateRepeat.isNotEmpty() -> withoutImmediateRepeat
else -> topicQuestions
}
@@ -41,6 +53,24 @@ class QuestionSelector(
return SelectedQuestion(weighted[random.value], random.nextState)
}
private fun Question.repeatsConceptFrom(other: Question): Boolean {
if (countryIds.none { it in other.countryIds }) return false
val family = skill.selectionFamily()
return family != SelectionFamily.Mixed && family == other.skill.selectionFamily()
}
private fun Skill.selectionFamily(): SelectionFamily = when (this) {
Skill.FlagRecognition,
Skill.CountryToFlag,
-> SelectionFamily.Flag
Skill.CapitalRecall,
Skill.CapitalRecognition,
-> SelectionFamily.Capital
Skill.MixedGeography,
Skill.FlagSymbolism,
-> SelectionFamily.Mixed
}
private fun matchesTopic(question: Question, topic: QuizTopic): Boolean = when (topic) {
QuizTopic.MostlyFlags -> true
QuizTopic.Capitals -> question.skill == Skill.CapitalRecall ||
@@ -71,14 +101,14 @@ class QuestionSelector(
}
val topicWeight = when (topic) {
QuizTopic.MostlyFlags -> when (question.skill) {
Skill.FlagRecognition -> 4
Skill.CountryToFlag -> 2
Skill.FlagRecognition -> 8
Skill.CountryToFlag -> 6
Skill.CapitalRecall,
Skill.CapitalRecognition,
-> 8
-> 1
Skill.MixedGeography,
Skill.FlagSymbolism,
-> 8
-> 2
}
QuizTopic.Capitals -> 1
QuizTopic.MixedGeography -> when (question.skill) {
@@ -96,3 +126,11 @@ class QuestionSelector(
return (difficultyWeight + reviewWeight) * topicWeight
}
}
private const val RECENT_QUESTION_WINDOW = 5
private enum class SelectionFamily {
Flag,
Capital,
Mixed,
}
@@ -107,13 +107,20 @@ class QuizEngine(
require(state.sessionRemainingMillis == null || state.sessionRemainingMillis >= 0) {
"A snapshot has an invalid session timer"
}
val completionPending = state.runCompletionPending ||
state.sessionExpired ||
(state.configuration.duration != QuizDuration.Unlimited && state.sessionRemainingMillis == 0L)
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(
runCompletionPending = completionPending,
tickingSinceMillis = null,
)
}
return state.copy(
phase = RunPhase.Paused,
pausedFromPhase = state.phase,
runCompletionPending = completionPending,
tickingSinceMillis = null,
)
}
@@ -130,7 +137,7 @@ class QuizEngine(
} else {
0
}
val hinted = timed.activeQuestion.hint != null
val hinted = timed.activeQuestion.appliedHints.isNotEmpty()
val overtime = evaluation.correct && timed.activeQuestion.questionRemainingMillis == 0L
val attempt = Attempt(
id = "${timed.runId}:${timed.attempts.size + 1}",
@@ -147,10 +154,11 @@ class QuizEngine(
pointsAwarded = points,
answeredAtEpochMillis = wallClock.nowEpochMillis(),
localEpochDay = localDateProvider.currentEpochDay(),
hintType = timed.activeQuestion.hint?.type,
hintType = timed.activeQuestion.appliedHints.lastOrNull()?.type,
hintPointsDeducted = timed.activeQuestion.hintPointsDeducted,
questionWindowMillis = timed.activeQuestion.questionWindowMillis,
questionRemainingMillis = timed.activeQuestion.questionRemainingMillis,
hintTypes = timed.activeQuestion.usedHintTypes,
)
val correctStreak = if (evaluation.correct) timed.currentCorrectStreak + 1 else 0
return timed.copy(
@@ -180,8 +188,9 @@ class QuizEngine(
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" }
require(state.activeQuestion.question.supportedHints.any { it !in state.activeQuestion.usedHintTypes }) {
"Every available hint has already been used for this question"
}
return freezeClocks(state).copy(phase = RunPhase.HintChooser)
}
@@ -189,11 +198,14 @@ class QuizEngine(
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" }
require(type !in state.activeQuestion.usedHintTypes) { "$type has already been used for this question" }
val scoreAfterHint = Scoring.deductHint(state.score)
val hintedQuestion = applyHint(state.activeQuestion, type).copy(
hintPointsDeducted = state.score - scoreAfterHint,
val appliedQuestion = applyHint(state.activeQuestion, type)
val chosenHint = requireNotNull(appliedQuestion.hint)
val hintedQuestion = appliedQuestion.copy(
usedHints = state.activeQuestion.appliedHints + chosenHint,
hintPointsDeducted = state.activeQuestion.hintPointsDeducted + state.score - scoreAfterHint,
)
return state.copy(
phase = RunPhase.Answering,
@@ -207,8 +219,9 @@ class QuizEngine(
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)
val timedRunComplete = state.runCompletionPending || state.sessionExpired
if (timedRunComplete || shouldShowScheduledFact(state)) {
return showRelatedFact(state, finalFact = timedRunComplete, mastery = mastery)
}
return moveToNextQuestion(state, mastery)
}
@@ -292,6 +305,23 @@ class QuizEngine(
}
}
fun finishExpiredRun(state: QuizRunState): QuizRunState {
requireActive(state)
require(state.configuration.duration != QuizDuration.Unlimited) { "Unlimited trivia does not expire" }
val frozen = freezeClocks(state)
require(frozen.sessionExpired || frozen.sessionRemainingMillis == 0L) { "Trivia time has not expired" }
return frozen.copy(
phase = RunPhase.Completed,
status = RunStatus.Completed,
feedback = null,
visibleFact = null,
finalFactPending = false,
runCompletionPending = false,
runAbandonmentPending = false,
tickingSinceMillis = null,
)
}
fun abandonRun(state: QuizRunState): QuizRunState {
requireActive(state)
return freezeClocks(state).copy(
@@ -329,7 +359,11 @@ class QuizEngine(
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" }
val placement = timed.activeQuestion.placements.firstOrNull { it.slotIndex == slotIndex }
?: error("The answer slot is empty")
require(placement.tileId !in timed.activeQuestion.revealedTileIds()) {
"A revealed letter cannot be removed"
}
return timed.copy(
activeQuestion = timed.activeQuestion.copy(
placements = timed.activeQuestion.placements.filterNot { it.slotIndex == slotIndex },
@@ -341,7 +375,12 @@ class QuizEngine(
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()))
val revealedTileIds = timed.activeQuestion.revealedTileIds()
return timed.copy(
activeQuestion = timed.activeQuestion.copy(
placements = timed.activeQuestion.placements.filter { it.tileId in revealedTileIds },
),
)
}
fun submitLetters(state: QuizRunState): QuizRunState {
@@ -356,6 +395,15 @@ class QuizEngine(
fun runResult(state: QuizRunState, improvedMasteryItems: Int): RunResult {
require(state.status == RunStatus.Completed) { "Only a completed run has results" }
return buildRunSummary(state, improvedMasteryItems)
}
fun abandonedRunSummary(state: QuizRunState, improvedMasteryItems: Int): RunResult {
require(state.status == RunStatus.Abandoned) { "Only an abandoned run has an abandoned summary" }
return buildRunSummary(state, improvedMasteryItems)
}
private fun buildRunSummary(state: QuizRunState, improvedMasteryItems: Int): RunResult {
val correct = state.attempts.count { it.correct }
val averageResponse = if (state.attempts.isEmpty()) 0 else {
state.attempts.sumOf { it.responseMillis } / state.attempts.size
@@ -374,6 +422,7 @@ class QuizEngine(
.flatMap { it.countryIds }
.distinct(),
completedAtEpochMillis = wallClock.nowEpochMillis(),
status = state.status,
)
}
@@ -540,6 +589,9 @@ class QuizEngine(
}
}
private fun ActiveQuestion.revealedTileIds(): Set<String> =
appliedHints.mapNotNullTo(linkedSetOf()) { it.revealedTileId }
private fun correctAnswer(question: Question): String = when (question) {
is Question.MultipleChoice -> question.choices.first { it.id == question.correctChoiceId }.label
is Question.LetterBank -> question.displayAnswer
@@ -1,8 +1,6 @@
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
@@ -10,5 +8,7 @@ object Scoring {
return (50 + speedPoints).coerceAtMost(100)
}
fun deductHint(score: Int): Int = (score - HINT_COST).coerceAtLeast(0)
fun hintCost(score: Int): Int = score.coerceAtLeast(0) / 2
fun deductHint(score: Int): Int = score.coerceAtLeast(0) - hintCost(score)
}
@@ -22,6 +22,7 @@ data class Attempt(
val hintPointsDeducted: Int = 0,
val questionWindowMillis: Long = 0,
val questionRemainingMillis: Long = 0,
val hintTypes: Set<HintType> = emptySet(),
)
@Serializable
@@ -88,6 +89,7 @@ data class RunResult(
val improvedMasteryItems: Int,
val revisitCountryIds: List<String>,
val completedAtEpochMillis: Long,
val status: RunStatus = RunStatus.Completed,
) {
val accuracyPercent: Int
get() = if (answeredQuestions == 0) 0 else (correctAnswers * 100) / answeredQuestions
@@ -52,10 +52,17 @@ data class ActiveQuestion(
val questionRemainingMillis: Long,
val questionElapsedMillis: Long = 0,
val hint: HintPresentation? = null,
val usedHints: List<HintPresentation> = emptyList(),
val hintPointsDeducted: Int = 0,
val removedChoiceIds: Set<String> = emptySet(),
val removedTileIds: Set<String> = emptySet(),
)
) {
val appliedHints: List<HintPresentation>
get() = if (usedHints.isEmpty()) listOfNotNull(hint) else usedHints
val usedHintTypes: Set<HintType>
get() = appliedHints.mapTo(linkedSetOf()) { it.type }
}
@Serializable
data class QuizRunState(