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