feat: moteur de jeu machine à états (chrono, feedback auto, fin de partie)
This commit is contained in:
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user