mirror of
https://github.com/priyanshujain/bertsatlas.git
synced 2026-10-02 19:06:59 +00:00
fix android trivia experience
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user