Add objectives, stage config, and GameEngine session core
Sealed Objective types (clearGems/reachScore/clearLines) with JSON round-trip; StageConfig with preset cells and star thresholds; GameEngine orchestrating placement -> clear -> scoring -> objectives with stuck detection, one-shot rescue (continue / +5 moves), and deterministic per-attempt RNG. 100-game headless stress test and pure-Dart architecture guard. 76 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import 'package:block_seasons/core/rng.dart';
|
||||
import 'package:block_seasons/game/engine/game_engine.dart';
|
||||
import 'package:block_seasons/game/models/grid.dart';
|
||||
import 'package:block_seasons/game/models/stage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
StageConfig _endlessStage(int seed) => StageConfig.fromJson({
|
||||
'id': 'stress_$seed',
|
||||
'seed': seed,
|
||||
'moveLimit': 40,
|
||||
'preset': const <Map<String, dynamic>>[],
|
||||
'objectives': [
|
||||
{'type': 'reachScore', 'target': 99999999},
|
||||
],
|
||||
'stars': {
|
||||
'two': {'movesLeft': 5},
|
||||
'three': {'movesLeft': 10},
|
||||
},
|
||||
'generatorProfile': 'mid',
|
||||
});
|
||||
|
||||
void main() {
|
||||
test('100 seeded random games run to a terminal state without violations',
|
||||
() {
|
||||
for (var seed = 0; seed < 100; seed++) {
|
||||
final engine = GameEngine(_endlessStage(seed));
|
||||
final rng = SeededRng(seed * 7919 + 1);
|
||||
var guard = 0;
|
||||
|
||||
while (engine.phase == GamePhase.playing) {
|
||||
guard++;
|
||||
expect(guard, lessThan(300), reason: 'seed $seed: game never ended');
|
||||
|
||||
// Enumerate all legal moves; the engine promises at least one exists
|
||||
// while phase == playing.
|
||||
final moves = <(int, int, int)>[];
|
||||
for (var i = 0; i < engine.tray.length; i++) {
|
||||
for (var y = 0; y < GridState.size; y++) {
|
||||
for (var x = 0; x < GridState.size; x++) {
|
||||
if (engine.tryPlaceWouldSucceed(i, x, y)) moves.add((i, x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(moves, isNotEmpty,
|
||||
reason: 'seed $seed: playing phase but no legal move');
|
||||
|
||||
final (i, x, y) = moves[rng.nextInt(moves.length)];
|
||||
final result = engine.tryPlace(i, x, y);
|
||||
expect(result, isNotNull, reason: 'seed $seed: legal move rejected');
|
||||
|
||||
// Invariants after every move.
|
||||
expect(engine.tray.length, inInclusiveRange(1, 3),
|
||||
reason: 'seed $seed');
|
||||
expect(engine.movesUsed, lessThanOrEqualTo(40), reason: 'seed $seed');
|
||||
expect(engine.score, greaterThanOrEqualTo(0), reason: 'seed $seed');
|
||||
}
|
||||
|
||||
// The score objective is unreachable, so every game ends stuck.
|
||||
expect(engine.phase, GamePhase.stuck, reason: 'seed $seed');
|
||||
expect(engine.stuckReason, isNotNull, reason: 'seed $seed');
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user