feat(domain): add pure GameEngine with TDD (removes pikachu cheat)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0d977e5cca
commit
e3a9831ce1
97
lib/domain/game/game_engine.dart
Normal file
97
lib/domain/game/game_engine.dart
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
108
test/domain/game_engine_test.dart
Normal file
108
test/domain/game_engine_test.dart
Normal file
@ -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;
|
||||
Loading…
x
Reference in New Issue
Block a user