feat: moteur de jeu machine à états (chrono, feedback auto, fin de partie)

This commit is contained in:
herel committed 2026-08-28 15:15:28 +02:00
1 parent 65ac01a2b2
commit c1a7a1f961
3 files changed
+274

No files matched your search

@@ -0,0 +1,118 @@
package com.livrets.game
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.time.Clock
import kotlin.time.TimeSource
class GameEngine(
private val config: GameConfig,
private val scope: CoroutineScope,
private val questionSource: QuestionSource = QuestionGenerator(),
private val onFinish: (GameResult) -> Unit = {},
) {
private val _state = MutableStateFlow<GameState>(GameState.NotStarted)
val state: StateFlow<GameState> = _state.asStateFlow()
private var timerJob: Job? = null
private var questionStartMark: TimeSource.Monotonic.ValueTimeMark? = null
private val records = mutableListOf<AnswerRecord>()
fun start() {
records.clear()
beginQuestion(index = 0, previous = null)
}
fun onDigit(digit: Char) {
val playing = _state.value as? GameState.Playing ?: return
if (playing.entry.length < MAX_ENTRY_LENGTH) {
_state.value = playing.copy(entry = playing.entry + digit)
}
}
fun onDelete() {
val playing = _state.value as? GameState.Playing ?: return
_state.value = playing.copy(entry = playing.entry.dropLast(1))
}
fun onValidate() {
val playing = _state.value as? GameState.Playing ?: return
if (playing.entry.isEmpty()) return
submit(playing.entry.toInt())
}
fun onTimeout() {
val playing = _state.value as? GameState.Playing ?: return
submit(playing.entry.takeIf { it.isNotEmpty() }?.toInt())
}
private fun submit(givenAnswer: Int?) {
val playing = _state.value as? GameState.Playing ?: return
timerJob?.cancel()
val record = AnswerRecord(
question = playing.question,
givenAnswer = givenAnswer,
correctAnswer = playing.question.correctAnswer,
isCorrect = givenAnswer == playing.question.correctAnswer,
durationMs = questionStartMark?.elapsedNow()?.inWholeMilliseconds ?: 0L,
)
records += record
_state.value = GameState.Feedback(record, playing.questionIndex)
scope.launch {
delay(FEEDBACK_DURATION_MS)
if (_state.value is GameState.Feedback) {
val next = playing.questionIndex + 1
if (next >= config.questionCount) finish()
else beginQuestion(index = next, previous = playing.question)
}
}
}
private fun beginQuestion(index: Int, previous: Question?) {
val question = questionSource.next(previous, config.maxTable)
questionStartMark = TimeSource.Monotonic.markNow()
_state.value = GameState.Playing(
question = question,
questionIndex = index,
totalQuestions = config.questionCount,
remainingMs = config.secondsPerQuestion * 1000L,
entry = "",
)
timerJob?.cancel()
timerJob = scope.launch { runTimer(question) }
}
private suspend fun runTimer(question: Question) {
val totalMs = config.secondsPerQuestion * 1000L
var elapsedMs = 0L
while (elapsedMs < totalMs) {
delay(TICK_MS)
elapsedMs += TICK_MS
val current = _state.value
if (current !is GameState.Playing || current.question != question) return
_state.value = current.copy(remainingMs = (totalMs - elapsedMs).coerceAtLeast(0L))
}
onTimeout()
}
private fun finish() {
val result = GameResult(
date = Clock.System.now(),
config = config,
records = records.toList(),
)
_state.value = GameState.Finished(result)
onFinish(result)
}
companion object {
const val TICK_MS = 100L
const val FEEDBACK_DURATION_MS = 1500L
const val MAX_ENTRY_LENGTH = 4
}
}
@@ -0,0 +1,20 @@
package com.livrets.game
sealed interface GameState {
data object NotStarted : GameState
data class Playing(
val question: Question,
val questionIndex: Int, // à partir de 0
val totalQuestions: Int,
val remainingMs: Long,
val entry: String,
) : GameState
data class Feedback(
val record: AnswerRecord,
val questionIndex: Int,
) : GameState
data class Finished(val result: GameResult) : GameState
}
@@ -0,0 +1,136 @@
package com.livrets.game
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class GameEngineTest {
private val fixedQuestion = Question.Direct(7, 8) // bonne réponse : 56
private fun GameEngine.playing(): GameState.Playing =
state.value as GameState.Playing
private fun GameEngine.feedback(): GameState.Feedback =
state.value as GameState.Feedback
@Test
fun `bonne réponse - feedback puis question suivante`() = runTest {
val engine = GameEngine(GameConfig(2, 10, 12), backgroundScope, QuestionSource { _, _ -> fixedQuestion })
engine.start()
engine.onDigit('5'); engine.onDigit('6'); engine.onValidate()
val fb = engine.feedback()
assertTrue(fb.record.isCorrect)
assertEquals(56, fb.record.givenAnswer)
advanceTimeBy(1600)
val next = engine.playing()
assertEquals(1, next.questionIndex)
assertEquals("", next.entry)
}
@Test
fun `mauvaise réponse - enregistre la réponse donnée et la bonne réponse`() = runTest {
val engine = GameEngine(GameConfig(2, 10, 12), backgroundScope, QuestionSource { _, _ -> fixedQuestion })
engine.start()
engine.onDigit('9'); engine.onValidate()
val fb = engine.feedback()
assertFalse(fb.record.isCorrect)
assertEquals(9, fb.record.givenAnswer)
assertEquals(56, fb.record.correctAnswer)
}
@Test
fun `validation avec saisie vide est ignorée`() = runTest {
val engine = GameEngine(GameConfig(2, 10, 12), backgroundScope, QuestionSource { _, _ -> fixedQuestion })
engine.start()
engine.onValidate()
assertTrue(engine.state.value is GameState.Playing)
engine.onDelete() // suppression sur saisie vide : sans effet
assertEquals("", engine.playing().entry)
}
@Test
fun `timeout sans saisie compte comme faux avec réponse nulle`() = runTest {
val engine = GameEngine(GameConfig(2, 5, 12), backgroundScope, QuestionSource { _, _ -> fixedQuestion })
engine.start()
advanceTimeBy(5100)
val fb = engine.feedback()
assertFalse(fb.record.isCorrect)
assertEquals(null, fb.record.givenAnswer)
}
@Test
fun `timeout avec saisie en cours auto-valide la saisie`() = runTest {
val engine = GameEngine(GameConfig(1, 5, 12), backgroundScope, QuestionSource { _, _ -> fixedQuestion })
engine.start()
engine.onDigit('5'); engine.onDigit('6')
advanceTimeBy(5100)
val fb = engine.feedback()
assertEquals(56, fb.record.givenAnswer)
assertTrue(fb.record.isCorrect)
advanceTimeBy(1600)
assertTrue(engine.state.value is GameState.Finished)
}
@Test
fun `fin de partie après N questions avec résultat complet`() = runTest {
var finished: GameResult? = null
val engine = GameEngine(
GameConfig(2, 10, 12),
backgroundScope,
QuestionSource { _, _ -> fixedQuestion },
) { finished = it }
engine.start()
repeat(2) {
engine.onDigit('5'); engine.onDigit('6'); engine.onValidate()
advanceTimeBy(1600)
}
val finishedState = engine.state.value as GameState.Finished
assertEquals(2, finishedState.result.records.size)
assertEquals(2, finishedState.result.correctCount)
assertEquals(2, finished?.records?.size)
}
@Test
fun `le chrono est en pause pendant le feedback`() = runTest {
val engine = GameEngine(GameConfig(50, 1, 12), backgroundScope, QuestionSource { _, _ -> fixedQuestion })
engine.start()
engine.onDigit('5'); engine.onDigit('6'); engine.onValidate()
advanceTimeBy(1499)
assertTrue(engine.state.value is GameState.Feedback)
advanceTimeBy(2)
assertTrue(engine.state.value is GameState.Playing)
}
@Test
fun `événements pendant le feedback sont ignorés`() = runTest {
val engine = GameEngine(GameConfig(2, 10, 12), backgroundScope, QuestionSource { _, _ -> fixedQuestion })
engine.start()
engine.onDigit('5'); engine.onDigit('6'); engine.onValidate()
engine.onDigit('1'); engine.onDigit('2'); engine.onValidate() // pendant Feedback : ignorés
assertEquals(0, engine.feedback().questionIndex)
}
}