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,24 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The game engine must stay pure Dart: unit-testable headless, reusable by
|
||||
/// the stage generator CLI, and free of widget-layer coupling.
|
||||
void main() {
|
||||
test('lib/game and lib/core never import Flutter', () {
|
||||
final dirs = [Directory('lib/game'), Directory('lib/core')];
|
||||
final violations = <String>[];
|
||||
for (final dir in dirs) {
|
||||
for (final entity in dir.listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||
final source = entity.readAsStringSync();
|
||||
if (source.contains("import 'package:flutter") ||
|
||||
source.contains('import "package:flutter')) {
|
||||
violations.add(entity.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations, isEmpty,
|
||||
reason: 'Flutter imports found in pure-Dart layers: $violations');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:block_seasons/core/rng.dart';
|
||||
import 'package:block_seasons/game/engine/game_engine.dart';
|
||||
import 'package:block_seasons/game/engine/piece_generator.dart';
|
||||
import 'package:block_seasons/game/models/cell.dart';
|
||||
import 'package:block_seasons/game/models/grid.dart';
|
||||
import 'package:block_seasons/game/models/piece_library.dart';
|
||||
import 'package:block_seasons/game/models/stage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
StageConfig _stage({
|
||||
int seed = 1234,
|
||||
int moveLimit = 20,
|
||||
List<PresetCell> preset = const [],
|
||||
List<Map<String, dynamic>> objectives = const [
|
||||
{'type': 'reachScore', 'target': 999999},
|
||||
],
|
||||
int twoStars = 5,
|
||||
int threeStars = 8,
|
||||
}) {
|
||||
return StageConfig.fromJson({
|
||||
'id': 'test_stage',
|
||||
'seed': seed,
|
||||
'moveLimit': moveLimit,
|
||||
'preset': [for (final c in preset) c.toJson()],
|
||||
'objectives': objectives,
|
||||
'stars': {
|
||||
'two': {'movesLeft': twoStars},
|
||||
'three': {'movesLeft': threeStars},
|
||||
},
|
||||
'generatorProfile': 'mid',
|
||||
});
|
||||
}
|
||||
|
||||
/// Row [y] filled except column 0; placing a mono at (0, y) clears it.
|
||||
List<PresetCell> _almostFullRow(int y) => [
|
||||
for (var x = 1; x < GridState.size; x++)
|
||||
PresetCell(x: x, y: y, type: CellType.filled),
|
||||
];
|
||||
|
||||
PieceGenerator _smallPool(int seed) => PieceGenerator(
|
||||
SeededRng(seed),
|
||||
pool: [
|
||||
PieceLibrary.byId('mono'),
|
||||
PieceLibrary.byId('domino_h'),
|
||||
PieceLibrary.byId('domino_v'),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('initial state', () {
|
||||
test('starts with preset grid, full tray, zero progress', () {
|
||||
final engine = GameEngine(_stage(preset: _almostFullRow(3)));
|
||||
expect(engine.phase, GamePhase.playing);
|
||||
expect(engine.tray, hasLength(3));
|
||||
expect(engine.score, 0);
|
||||
expect(engine.movesUsed, 0);
|
||||
expect(engine.grid.occupiedCount, 7);
|
||||
});
|
||||
|
||||
test('same stage and attempt deal identical trays', () {
|
||||
final a = GameEngine(_stage());
|
||||
final b = GameEngine(_stage());
|
||||
expect(a.tray.map((p) => p.id), b.tray.map((p) => p.id));
|
||||
});
|
||||
|
||||
test('different attempts deal different trays', () {
|
||||
final trays = <String>{};
|
||||
for (var attempt = 0; attempt < 5; attempt++) {
|
||||
final engine = GameEngine(_stage(), attempt: attempt);
|
||||
trays.add(engine.tray.map((p) => p.id).join(','));
|
||||
}
|
||||
expect(trays.length, greaterThan(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('tryPlace', () {
|
||||
test('rejects illegal placements without consuming state', () {
|
||||
final engine = GameEngine(_stage(preset: _almostFullRow(3)));
|
||||
final before = engine.tray.length;
|
||||
// (1,3) is occupied by the preset.
|
||||
final result = engine.tryPlace(0, 1, 3);
|
||||
expect(result, isNull);
|
||||
expect(engine.tray.length, before);
|
||||
expect(engine.movesUsed, 0);
|
||||
});
|
||||
|
||||
test('placement consumes the piece, scores cells, advances moves', () {
|
||||
final engine = GameEngine(_stage(), generator: _smallPool(1));
|
||||
final piece = engine.tray[0];
|
||||
final result = engine.tryPlace(0, 0, 0);
|
||||
expect(result, isNotNull);
|
||||
expect(engine.movesUsed, 1);
|
||||
expect(engine.tray, hasLength(2));
|
||||
expect(engine.score, piece.size);
|
||||
});
|
||||
|
||||
test('tray refills to 3 after all pieces are used', () {
|
||||
final engine = GameEngine(_stage(), generator: _smallPool(1));
|
||||
engine.tryPlace(0, 0, 0);
|
||||
engine.tryPlace(0, 0, 3);
|
||||
expect(engine.tray, hasLength(1));
|
||||
engine.tryPlace(0, 0, 6);
|
||||
expect(engine.tray, hasLength(3));
|
||||
});
|
||||
|
||||
test('completing a row clears it and applies combo-multiplied score', () {
|
||||
final engine = GameEngine(
|
||||
_stage(preset: _almostFullRow(3)),
|
||||
generator: _smallPool(1),
|
||||
);
|
||||
final monoIndex = engine.tray.indexWhere((p) => p.id == 'mono');
|
||||
final result = engine.tryPlace(monoIndex, 0, 3)!;
|
||||
expect(result.linesCleared, 1);
|
||||
// 1 cell + round(100 * 1.5) = 151
|
||||
expect(engine.score, 151);
|
||||
expect(engine.grid.occupiedCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('win and stars', () {
|
||||
test('completing all objectives wins with stars from moves left', () {
|
||||
final engine = GameEngine(
|
||||
_stage(
|
||||
preset: _almostFullRow(3),
|
||||
objectives: [
|
||||
{'type': 'clearLines', 'count': 1},
|
||||
],
|
||||
moveLimit: 10,
|
||||
),
|
||||
generator: _smallPool(1),
|
||||
);
|
||||
final monoIndex = engine.tray.indexWhere((p) => p.id == 'mono');
|
||||
engine.tryPlace(monoIndex, 0, 3);
|
||||
expect(engine.phase, GamePhase.won);
|
||||
// Won on move 1 of 10 -> 9 left >= 8 -> 3 stars.
|
||||
expect(engine.starsEarned, 3);
|
||||
});
|
||||
|
||||
test('gem clears feed the clearGems objective', () {
|
||||
final engine = GameEngine(
|
||||
_stage(
|
||||
preset: [
|
||||
const PresetCell(x: 7, y: 3, type: CellType.gem),
|
||||
..._almostFullRow(3).where((c) => c.x != 7),
|
||||
],
|
||||
objectives: [
|
||||
{'type': 'clearGems', 'count': 1},
|
||||
],
|
||||
),
|
||||
generator: _smallPool(1),
|
||||
);
|
||||
final monoIndex = engine.tray.indexWhere((p) => p.id == 'mono');
|
||||
engine.tryPlace(monoIndex, 0, 3);
|
||||
expect(engine.phase, GamePhase.won);
|
||||
});
|
||||
});
|
||||
|
||||
group('out of moves and rescue', () {
|
||||
test('exhausting the move limit gets stuck, extra moves resume', () {
|
||||
final engine = GameEngine(
|
||||
_stage(moveLimit: 1),
|
||||
generator: _smallPool(1),
|
||||
);
|
||||
engine.tryPlace(0, 0, 0);
|
||||
expect(engine.phase, GamePhase.stuck);
|
||||
expect(engine.stuckReason, StuckReason.outOfMoves);
|
||||
|
||||
engine.addExtraMoves();
|
||||
expect(engine.phase, GamePhase.playing);
|
||||
expect(engine.movesLeft, 5);
|
||||
|
||||
engine.declineAndLose();
|
||||
expect(engine.phase, GamePhase.lost);
|
||||
});
|
||||
|
||||
test('rescue is one-shot per attempt', () {
|
||||
final engine = GameEngine(
|
||||
_stage(moveLimit: 1),
|
||||
generator: _smallPool(1),
|
||||
);
|
||||
engine.tryPlace(0, 0, 0);
|
||||
engine.addExtraMoves();
|
||||
// Burn the granted moves without winning.
|
||||
engine.tryPlace(0, 0, 2);
|
||||
engine.tryPlace(0, 0, 4);
|
||||
engine.tryPlace(0, 2, 0);
|
||||
engine.tryPlace(0, 2, 2);
|
||||
engine.tryPlace(0, 2, 4);
|
||||
expect(engine.phase, GamePhase.stuck);
|
||||
expect(() => engine.addExtraMoves(), throwsStateError);
|
||||
});
|
||||
});
|
||||
|
||||
group('dead board and continue', () {
|
||||
StageConfig deadStage() {
|
||||
// Checkerboard: only monos fit, and the injected pool has none small
|
||||
// enough, so the board is dead from the deal.
|
||||
final preset = <PresetCell>[
|
||||
for (var y = 0; y < GridState.size; y++)
|
||||
for (var x = 0; x < GridState.size; x++)
|
||||
if ((x + y).isEven) PresetCell(x: x, y: y, type: CellType.filled),
|
||||
];
|
||||
return _stage(preset: preset, moveLimit: 50);
|
||||
}
|
||||
|
||||
PieceGenerator bigPool(int seed) => PieceGenerator(
|
||||
SeededRng(seed),
|
||||
pool: [
|
||||
PieceLibrary.byId('square3'),
|
||||
PieceLibrary.byId('line5_h'),
|
||||
PieceLibrary.byId('line5_v'),
|
||||
],
|
||||
);
|
||||
|
||||
test('a dead deal is detected immediately', () {
|
||||
final engine = GameEngine(deadStage(), generator: bigPool(1));
|
||||
expect(engine.phase, GamePhase.stuck);
|
||||
expect(engine.stuckReason, StuckReason.boardDead);
|
||||
});
|
||||
|
||||
test('continue clears the two most-filled rows and redeals', () {
|
||||
final engine = GameEngine(deadStage(), generator: bigPool(1));
|
||||
engine.useContinue();
|
||||
expect(engine.phase, GamePhase.playing);
|
||||
// 32 checkerboard cells minus two cleared rows (4 each).
|
||||
expect(engine.grid.occupiedCount, 24);
|
||||
expect(() => engine.useContinue(), throwsStateError);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:block_seasons/game/engine/game_event.dart';
|
||||
import 'package:block_seasons/game/models/objective.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('ClearGems', () {
|
||||
test('accumulates gems from clear events', () {
|
||||
var obj = Objective.clearGems(6);
|
||||
obj = obj.onEvent(const LinesCleared(lines: 1, gems: 2));
|
||||
obj = obj.onEvent(const LinesCleared(lines: 2, gems: 3));
|
||||
expect(obj.current, 5);
|
||||
expect(obj.isComplete, isFalse);
|
||||
obj = obj.onEvent(const LinesCleared(lines: 1, gems: 1));
|
||||
expect(obj.isComplete, isTrue);
|
||||
});
|
||||
|
||||
test('ignores unrelated events', () {
|
||||
var obj = Objective.clearGems(3);
|
||||
obj = obj.onEvent(const ScoreChanged(total: 9999));
|
||||
expect(obj.current, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('ReachScore', () {
|
||||
test('tracks the running total score', () {
|
||||
var obj = Objective.reachScore(500);
|
||||
obj = obj.onEvent(const ScoreChanged(total: 320));
|
||||
expect(obj.current, 320);
|
||||
expect(obj.isComplete, isFalse);
|
||||
obj = obj.onEvent(const ScoreChanged(total: 510));
|
||||
expect(obj.isComplete, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClearLines', () {
|
||||
test('accumulates cleared lines', () {
|
||||
var obj = Objective.clearLines(4);
|
||||
obj = obj.onEvent(const LinesCleared(lines: 3, gems: 0));
|
||||
expect(obj.current, 3);
|
||||
obj = obj.onEvent(const LinesCleared(lines: 1, gems: 0));
|
||||
expect(obj.isComplete, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('JSON', () {
|
||||
test('round-trips all objective types', () {
|
||||
for (final json in [
|
||||
{'type': 'clearGems', 'count': 6},
|
||||
{'type': 'reachScore', 'target': 1200},
|
||||
{'type': 'clearLines', 'count': 10},
|
||||
]) {
|
||||
final obj = Objective.fromJson(json);
|
||||
expect(obj.toJson(), json);
|
||||
expect(obj.current, 0);
|
||||
expect(obj.target, greaterThan(0));
|
||||
}
|
||||
});
|
||||
|
||||
test('throws on unknown type', () {
|
||||
expect(
|
||||
() => Objective.fromJson({'type': 'collectStars', 'count': 1}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:block_seasons/game/models/cell.dart';
|
||||
import 'package:block_seasons/game/models/objective.dart';
|
||||
import 'package:block_seasons/game/models/stage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const sampleJson = {
|
||||
'id': 's001_007',
|
||||
'seed': 8841273,
|
||||
'moveLimit': 22,
|
||||
'preset': [
|
||||
{'x': 3, 'y': 4, 't': 'gem'},
|
||||
{'x': 0, 'y': 7, 't': 'filled', 'c': 2},
|
||||
],
|
||||
'objectives': [
|
||||
{'type': 'clearGems', 'count': 6},
|
||||
],
|
||||
'stars': {
|
||||
'two': {'movesLeft': 4},
|
||||
'three': {'movesLeft': 8},
|
||||
},
|
||||
'generatorProfile': 'mid',
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('StageConfig', () {
|
||||
test('parses the season pack stage schema', () {
|
||||
final stage = StageConfig.fromJson(sampleJson);
|
||||
expect(stage.id, 's001_007');
|
||||
expect(stage.seed, 8841273);
|
||||
expect(stage.moveLimit, 22);
|
||||
expect(stage.preset, hasLength(2));
|
||||
expect(stage.preset[0].type, CellType.gem);
|
||||
expect(stage.preset[1].colorId, 2);
|
||||
expect(stage.objectives.single, isA<Objective>());
|
||||
expect(stage.generatorProfile, 'mid');
|
||||
});
|
||||
|
||||
test('round-trips to JSON', () {
|
||||
final stage = StageConfig.fromJson(sampleJson);
|
||||
expect(stage.toJson(), sampleJson);
|
||||
});
|
||||
|
||||
test('star thresholds map moves left to stars', () {
|
||||
final stage = StageConfig.fromJson(sampleJson);
|
||||
expect(stage.stars.starsFor(movesLeft: 0), 1);
|
||||
expect(stage.stars.starsFor(movesLeft: 3), 1);
|
||||
expect(stage.stars.starsFor(movesLeft: 4), 2);
|
||||
expect(stage.stars.starsFor(movesLeft: 7), 2);
|
||||
expect(stage.stars.starsFor(movesLeft: 8), 3);
|
||||
expect(stage.stars.starsFor(movesLeft: 20), 3);
|
||||
});
|
||||
|
||||
test('builds the initial grid from preset cells', () {
|
||||
final stage = StageConfig.fromJson(sampleJson);
|
||||
final grid = stage.initialGrid();
|
||||
expect(grid.cellAt(3, 4).type, CellType.gem);
|
||||
expect(grid.cellAt(0, 7).type, CellType.filled);
|
||||
expect(grid.cellAt(0, 7).colorId, 2);
|
||||
expect(grid.occupiedCount, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user