diff --git a/lib/domain/game/game_engine.dart b/lib/domain/game/game_engine.dart new file mode 100644 index 0000000..3f16add --- /dev/null +++ b/lib/domain/game/game_engine.dart @@ -0,0 +1,97 @@ +import '../../core/config/app_constants.dart'; +import '../entities/pokemon.dart'; +import 'game_state.dart'; + +/// Règles du jeu, pures (aucune IO, aucun widget). Entièrement testable. +class GameEngine { + const GameEngine(); + + /// Nouvelle partie : réinitialise tout sauf le bestScore. + GameState newGame(GameState s) { + return s.copyWith( + lives: AppConstants.startingLives, + skips: AppConstants.startingSkips, + hints: AppConstants.startingHints, + sessionCorrectCount: 0, + currentScore: 0, + status: GameStatus.loading, + isHintUsed: false, + ); + } + + /// Démarre une manche avec un nouveau Pokémon. + GameState startRound(GameState s, Pokemon p, {required bool isShiny}) { + return s.copyWith( + currentPokemon: p, + isShiny: isShiny, + isHintUsed: false, + status: GameStatus.playing, + ); + } + + /// Soumet une réponse. Renvoie le nouvel état + la nature du résultat. + GuessOutcome submitGuess(GameState s, String guess) { + final pokemon = s.currentPokemon; + if (pokemon == null || s.status != GameStatus.playing) { + return GuessOutcome(s, GuessResult.invalid); + } + + final normalizedGuess = _normalize(guess.trim().toLowerCase()); + final normalizedActual = _normalize(pokemon.name.toLowerCase()); + + if (normalizedGuess == normalizedActual) { + final newSession = s.sessionCorrectCount + 1; + final gained = s.isShiny ? AppConstants.pointsShiny : AppConstants.pointsNormal; + final newScore = s.currentScore + gained; + final hintBonus = + newSession % AppConstants.hintBonusEvery == 0 ? 1 : 0; + final skipBonus = + newSession % AppConstants.skipBonusEvery == 0 ? 1 : 0; + + final newState = s.copyWith( + currentPokemon: pokemon.copyWith(isCaught: true, isSeen: true), + currentScore: newScore, + bestScore: newScore > s.bestScore ? newScore : s.bestScore, + sessionCorrectCount: newSession, + hints: s.hints + hintBonus, + skips: s.skips + skipBonus, + status: GameStatus.roundWon, + ); + return GuessOutcome(newState, GuessResult.correct); + } + + final remainingLives = s.lives - 1; + if (remainingLives <= 0) { + return GuessOutcome( + s.copyWith(lives: 0, status: GameStatus.gameOver), + GuessResult.gameOver, + ); + } + return GuessOutcome( + s.copyWith(lives: remainingLives), + GuessResult.wrong, + ); + } + + /// Consomme un indice si disponible et non déjà utilisé. + GameState useHint(GameState s) { + if (s.isHintUsed || s.hints <= 0) return s; + return s.copyWith(isHintUsed: true, hints: s.hints - 1); + } + + /// Consomme un skip si disponible. + GameState useSkip(GameState s) { + if (s.skips <= 0) return s; + return s.copyWith(skips: s.skips - 1); + } + + static String _normalize(String input) { + const withDia = 'ÀÁÂÃÄÅàáâãäåÒÓÔÕÖØòóôõöøÈÉÊËèéêëÇçÌÍÎÏìíîïÙÚÛÜùúûüÿÑñ'; + const withoutDia = 'AAAAAAaaaaaaOOOOOOooooooEEEEeeeeCcIIIIiiiiUUUUuuuuyNn'; + var output = input; + for (var i = 0; i < withDia.length; i++) { + output = output.replaceAll(withDia[i], withoutDia[i]); + } + return output; + } +} diff --git a/test/domain/game_engine_test.dart b/test/domain/game_engine_test.dart new file mode 100644 index 0000000..f810de2 --- /dev/null +++ b/test/domain/game_engine_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pokeguess/domain/entities/pokemon.dart'; +import 'package:pokeguess/domain/game/game_engine.dart'; +import 'package:pokeguess/domain/game/game_state.dart'; + +const _engine = GameEngine(); + +Pokemon _poke(String name) => Pokemon( + name: name, id: 1, type1: PokemonType.normal, + hp: 1, atk: 1, def: 1, spd: 1, + ); + +GameState _playing(String name, {bool shiny = false}) => _engine.startRound( + const GameState(), _poke(name), isShiny: shiny); + +void main() { + test('newGame réinitialise vies/score/session, garde le bestScore', () { + const s = GameState( + lives: 1, currentScore: 50, sessionCorrectCount: 7, bestScore: 99); + final r = _engine.newGame(s); + expect(r.lives, 3); + expect(r.currentScore, 0); + expect(r.sessionCorrectCount, 0); + expect(r.bestScore, 99); + expect(r.status, GameStatus.loading); + }); + + test('startRound place le Pokémon et passe en playing', () { + final r = _playing('pikachu', shiny: true); + expect(r.currentPokemon?.name, 'pikachu'); + expect(r.isShiny, true); + expect(r.isHintUsed, false); + expect(r.status, GameStatus.playing); + }); + + test('bonne réponse: +10 points et roundWon', () { + final o = _engine.submitGuess(_playing('pikachu'), 'pikachu'); + expect(o.result, GuessResult.correct); + expect(o.state.currentScore, 10); + expect(o.state.sessionCorrectCount, 1); + expect(o.state.status, GameStatus.roundWon); + expect(o.state.currentPokemon?.isCaught, true); + }); + + test('bonne réponse shiny: +20 points', () { + final o = _engine.submitGuess(_playing('pikachu', shiny: true), 'pikachu'); + expect(o.state.currentScore, 20); + }); + + test('comparaison insensible aux accents et à la casse', () { + final o = _engine.submitGuess(_playing('Dracaufeu'), 'dracaufeu'); + expect(o.result, GuessResult.correct); + final o2 = _engine.submitGuess(_playing('Électhor'), 'electhor'); + expect(o2.result, GuessResult.correct); + }); + + test('mauvaise réponse: -1 vie, reste playing tant qu\'il reste des vies', () { + final o = _engine.submitGuess(_playing('pikachu'), 'salameche'); + expect(o.result, GuessResult.wrong); + expect(o.state.lives, 2); + expect(o.state.status, GameStatus.playing); + }); + + test('mauvaise réponse à 1 vie: gameOver', () { + const start = GameState(lives: 1, status: GameStatus.playing); + final round = _engine.startRound(start, _poke('pikachu'), isShiny: false); + final o = _engine.submitGuess(round, 'faux'); + expect(o.result, GuessResult.gameOver); + expect(o.state.lives, 0); + expect(o.state.status, GameStatus.gameOver); + }); + + test('le cheat "pikachu" n\'existe plus: faux nom sur un autre Pokémon = wrong', () { + final o = _engine.submitGuess(_playing('bulbizarre'), 'pikachu'); + expect(o.result, GuessResult.wrong); + }); + + test('bonus: indice tous les 5, skip tous les 10', () { + var s = const GameState(sessionCorrectCount: 4, status: GameStatus.playing); + s = _engine.startRound(s, _poke('pikachu'), isShiny: false); + final o = _engine.submitGuess(s, 'pikachu'); // 5e bonne réponse + expect(o.state.sessionCorrectCount, 5); + expect(o.state.hints, AppConstantsHints + 1); + }); + + test('useHint consomme un indice', () { + final s = _playing('pikachu'); + final r = _engine.useHint(s); + expect(r.isHintUsed, true); + expect(r.hints, 2); + }); + + test('useHint sans indice restant ne change rien', () { + final s = _playing('pikachu').copyWith(hints: 0); + final r = _engine.useHint(s); + expect(r.isHintUsed, false); + expect(r.hints, 0); + }); + + test('useSkip décrémente les skips', () { + final s = _playing('pikachu'); + final r = _engine.useSkip(s); + expect(r.skips, 2); + }); +} + +/// Valeur attendue de hints au démarrage (miroir d'AppConstants.startingHints = 3). +const AppConstantsHints = 3;