diff --git a/sharedUI/src/commonMain/kotlin/com/livrets/game/GameEngine.kt b/sharedUI/src/commonMain/kotlin/com/livrets/game/GameEngine.kt new file mode 100644 index 0000000..d480401 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/com/livrets/game/GameEngine.kt @@ -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.NotStarted) + val state: StateFlow = _state.asStateFlow() + + private var timerJob: Job? = null + private var questionStartMark: TimeSource.Monotonic.ValueTimeMark? = null + private val records = mutableListOf() + + 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 + } +} diff --git a/sharedUI/src/commonMain/kotlin/com/livrets/game/GameState.kt b/sharedUI/src/commonMain/kotlin/com/livrets/game/GameState.kt new file mode 100644 index 0000000..dfc4650 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/com/livrets/game/GameState.kt @@ -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 +} diff --git a/sharedUI/src/commonTest/kotlin/com/livrets/game/GameEngineTest.kt b/sharedUI/src/commonTest/kotlin/com/livrets/game/GameEngineTest.kt new file mode 100644 index 0000000..18584fa --- /dev/null +++ b/sharedUI/src/commonTest/kotlin/com/livrets/game/GameEngineTest.kt @@ -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) + } +}