diff --git a/lib/game/engine/game_engine.dart b/lib/game/engine/game_engine.dart new file mode 100644 index 0000000..a0466ba --- /dev/null +++ b/lib/game/engine/game_engine.dart @@ -0,0 +1,200 @@ +import '../../core/rng.dart'; +import '../models/cell.dart'; +import '../models/grid.dart'; +import '../models/objective.dart'; +import '../models/piece.dart'; +import '../models/stage.dart'; +import 'game_event.dart'; +import 'line_clear.dart'; +import 'piece_generator.dart'; +import 'placement.dart'; +import 'scoring.dart'; + +enum GamePhase { playing, won, stuck, lost } + +enum StuckReason { outOfMoves, boardDead } + +class PlacementResult { + const PlacementResult({ + required this.events, + required this.pointsGained, + required this.linesCleared, + required this.gemsCleared, + }); + + final List events; + final int pointsGained; + final int linesCleared; + final int gemsCleared; +} + +/// The single stateful pure-Dart session object for one stage attempt. +/// UI layers wrap this behind a notifier; it never imports Flutter. +class GameEngine { + GameEngine(this._stage, {int attempt = 0, PieceGenerator? generator}) + : _generator = + generator ?? PieceGenerator(SeededRng(_stage.seed ^ attempt)), + _grid = _stage.initialGrid(), + _objectives = List.of(_stage.objectives), + _moveLimit = _stage.moveLimit { + _tray = _generator.nextTray(_grid); + _checkStuck(); + } + + static const int extraMovesGrant = 5; + static const int continueRowsCleared = 2; + + final StageConfig _stage; + final PieceGenerator _generator; + + GridState _grid; + late List _tray; + List _objectives; + int _moveLimit; + int _movesUsed = 0; + int _score = 0; + ComboState _combo = ComboState.initial; + GamePhase _phase = GamePhase.playing; + StuckReason? _stuckReason; + bool _rescueUsed = false; + + GridState get grid => _grid; + List get tray => List.unmodifiable(_tray); + int get score => _score; + ComboState get combo => _combo; + int get movesUsed => _movesUsed; + int get movesLeft => _moveLimit - _movesUsed; + List get objectives => List.unmodifiable(_objectives); + GamePhase get phase => _phase; + StuckReason? get stuckReason => _stuckReason; + + int get starsEarned => _phase == GamePhase.won + ? _stage.stars.starsFor(movesLeft: movesLeft) + : 0; + + /// Overall objective completion in [0, 1] for the near-miss screen. + double get objectiveProgress { + if (_objectives.isEmpty) return 0; + var total = 0.0; + for (final obj in _objectives) { + final frac = obj.current / obj.target; + total += frac > 1 ? 1 : frac; + } + return total / _objectives.length; + } + + /// Read-only legality probe (drag ghost preview, AI players, tests). + bool tryPlaceWouldSucceed(int trayIndex, int x, int y) => + _phase == GamePhase.playing && canPlace(_grid, _tray[trayIndex], x, y); + + PlacementResult? tryPlace(int trayIndex, int x, int y) { + if (_phase != GamePhase.playing) return null; + final piece = _tray[trayIndex]; + if (!canPlace(_grid, piece, x, y)) return null; + + _tray.removeAt(trayIndex); + _movesUsed++; + + final placed = place(_grid, piece, x, y); + final clear = detectAndClear(placed); + _grid = clear.grid; + + final delta = scorePlacement( + cellsPlaced: piece.size, + linesCleared: clear.linesCleared, + combo: _combo, + ); + _score += delta.points; + _combo = delta.combo; + + final events = [ + PiecePlaced(piece: piece, x: x, y: y), + if (clear.linesCleared > 0) + LinesCleared(lines: clear.linesCleared, gems: clear.gemsCleared), + ScoreChanged(total: _score), + ]; + _objectives = [ + for (final obj in _objectives) + events.fold(obj, (o, event) => o.onEvent(event)), + ]; + + if (_objectives.every((o) => o.isComplete)) { + _phase = GamePhase.won; + } else { + if (_tray.isEmpty) _tray = _generator.nextTray(_grid); + _checkStuck(); + } + + return PlacementResult( + events: events, + pointsGained: delta.points, + linesCleared: clear.linesCleared, + gemsCleared: clear.gemsCleared, + ); + } + + void _checkStuck() { + if (movesLeft <= 0) { + _phase = GamePhase.stuck; + _stuckReason = StuckReason.outOfMoves; + } else if (!anyPlacementExists(_grid, _tray)) { + _phase = GamePhase.stuck; + _stuckReason = StuckReason.boardDead; + } else { + _phase = GamePhase.playing; + _stuckReason = null; + } + } + + /// Rewarded-ad continue for a dead board: clears the two most-filled rows + /// and redeals. One rescue (continue or extra moves) per attempt. + void useContinue() { + if (_phase != GamePhase.stuck || + _stuckReason != StuckReason.boardDead || + _rescueUsed) { + throw StateError('continue not available'); + } + _rescueUsed = true; + + final rowCounts = <(int, int)>[]; + for (var y = 0; y < GridState.size; y++) { + var count = 0; + for (var x = 0; x < GridState.size; x++) { + if (_grid.isOccupied(x, y)) count++; + } + rowCounts.add((y, count)); + } + rowCounts.sort((a, b) { + final c = b.$2.compareTo(a.$2); + return c != 0 ? c : a.$1.compareTo(b.$1); + }); + for (final (y, _) in rowCounts.take(continueRowsCleared)) { + for (var x = 0; x < GridState.size; x++) { + _grid = _grid.withCell(x, y, const Cell(CellType.empty)); + } + } + + _tray = _generator.nextTray(_grid); + _checkStuck(); + } + + /// Rewarded-ad rescue for move exhaustion: +5 moves. One rescue per attempt. + void addExtraMoves() { + if (_phase != GamePhase.stuck || + _stuckReason != StuckReason.outOfMoves || + _rescueUsed) { + throw StateError('extra moves not available'); + } + _rescueUsed = true; + _moveLimit += extraMovesGrant; + _checkStuck(); + } + + /// Player declines rescue (or forfeits): the attempt is lost. + void declineAndLose() { + if (_phase == GamePhase.won || _phase == GamePhase.lost) { + throw StateError('attempt already finished'); + } + _phase = GamePhase.lost; + } +} diff --git a/lib/game/engine/game_event.dart b/lib/game/engine/game_event.dart new file mode 100644 index 0000000..cf97dc4 --- /dev/null +++ b/lib/game/engine/game_event.dart @@ -0,0 +1,28 @@ +import '../models/piece.dart'; + +/// Events emitted by the engine during a placement, consumed by objectives +/// and (later) the UI effects layer. +sealed class GameEvent { + const GameEvent(); +} + +class PiecePlaced extends GameEvent { + const PiecePlaced({required this.piece, required this.x, required this.y}); + + final Piece piece; + final int x; + final int y; +} + +class LinesCleared extends GameEvent { + const LinesCleared({required this.lines, required this.gems}); + + final int lines; + final int gems; +} + +class ScoreChanged extends GameEvent { + const ScoreChanged({required this.total}); + + final int total; +} diff --git a/lib/game/engine/piece_generator.dart b/lib/game/engine/piece_generator.dart index 390e68d..be1d17f 100644 --- a/lib/game/engine/piece_generator.dart +++ b/lib/game/engine/piece_generator.dart @@ -97,7 +97,7 @@ class PieceGenerator { break; } } - final tray = [if (largestFitting != null) largestFitting]; + final tray = [?largestFitting]; for (final p in bySizeDesc.reversed) { if (tray.length == traySize) break; if (tray.any((t) => t.id == p.id)) continue; diff --git a/lib/game/models/objective.dart b/lib/game/models/objective.dart new file mode 100644 index 0000000..368e02a --- /dev/null +++ b/lib/game/models/objective.dart @@ -0,0 +1,88 @@ +import '../engine/game_event.dart'; + +/// Stage win condition with immutable progress tracking. New objective types +/// plug in as subclasses with a JSON tag. +sealed class Objective { + const Objective(); + + factory Objective.clearGems(int count) => ClearGemsObjective(count, 0); + factory Objective.reachScore(int target) => ReachScoreObjective(target, 0); + factory Objective.clearLines(int count) => ClearLinesObjective(count, 0); + + factory Objective.fromJson(Map json) { + switch (json['type']) { + case 'clearGems': + return Objective.clearGems(json['count'] as int); + case 'reachScore': + return Objective.reachScore(json['target'] as int); + case 'clearLines': + return Objective.clearLines(json['count'] as int); + default: + throw FormatException('Unknown objective type: ${json['type']}'); + } + } + + int get target; + int get current; + bool get isComplete => current >= target; + + Objective onEvent(GameEvent event); + + Map toJson(); +} + +class ClearGemsObjective extends Objective { + const ClearGemsObjective(this.target, this.current); + + @override + final int target; + @override + final int current; + + @override + Objective onEvent(GameEvent event) => switch (event) { + LinesCleared(:final gems) => + ClearGemsObjective(target, current + gems), + _ => this, + }; + + @override + Map toJson() => {'type': 'clearGems', 'count': target}; +} + +class ReachScoreObjective extends Objective { + const ReachScoreObjective(this.target, this.current); + + @override + final int target; + @override + final int current; + + @override + Objective onEvent(GameEvent event) => switch (event) { + ScoreChanged(:final total) => ReachScoreObjective(target, total), + _ => this, + }; + + @override + Map toJson() => {'type': 'reachScore', 'target': target}; +} + +class ClearLinesObjective extends Objective { + const ClearLinesObjective(this.target, this.current); + + @override + final int target; + @override + final int current; + + @override + Objective onEvent(GameEvent event) => switch (event) { + LinesCleared(:final lines) => + ClearLinesObjective(target, current + lines), + _ => this, + }; + + @override + Map toJson() => {'type': 'clearLines', 'count': target}; +} diff --git a/lib/game/models/stage.dart b/lib/game/models/stage.dart new file mode 100644 index 0000000..c186785 --- /dev/null +++ b/lib/game/models/stage.dart @@ -0,0 +1,124 @@ +import 'cell.dart'; +import 'grid.dart'; +import 'objective.dart'; + +/// A pre-placed cell in a stage layout. +class PresetCell { + const PresetCell({ + required this.x, + required this.y, + required this.type, + this.colorId = 0, + }); + + factory PresetCell.fromJson(Map json) => PresetCell( + x: json['x'] as int, + y: json['y'] as int, + type: switch (json['t']) { + 'gem' => CellType.gem, + 'filled' => CellType.filled, + _ => throw FormatException('Unknown preset cell type: ${json['t']}'), + }, + colorId: (json['c'] as int?) ?? 0, + ); + + final int x; + final int y; + final CellType type; + final int colorId; + + Map toJson() => { + 'x': x, + 'y': y, + 't': type.name, + if (type == CellType.filled) 'c': colorId, + }; +} + +/// Star rating from moves remaining at the win. +class StarThresholds { + const StarThresholds({ + required this.twoMovesLeft, + required this.threeMovesLeft, + }); + + factory StarThresholds.fromJson(Map json) => + StarThresholds( + twoMovesLeft: (json['two'] as Map)['movesLeft'] as int, + threeMovesLeft: + (json['three'] as Map)['movesLeft'] as int, + ); + + final int twoMovesLeft; + final int threeMovesLeft; + + int starsFor({required int movesLeft}) { + if (movesLeft >= threeMovesLeft) return 3; + if (movesLeft >= twoMovesLeft) return 2; + return 1; + } + + Map toJson() => { + 'two': {'movesLeft': twoMovesLeft}, + 'three': {'movesLeft': threeMovesLeft}, + }; +} + +/// Immutable stage definition, the unit of season pack content. +class StageConfig { + const StageConfig({ + required this.id, + required this.seed, + required this.moveLimit, + required this.preset, + required this.objectives, + required this.stars, + required this.generatorProfile, + }); + + factory StageConfig.fromJson(Map json) => StageConfig( + id: json['id'] as String, + seed: json['seed'] as int, + moveLimit: json['moveLimit'] as int, + preset: [ + for (final cell in (json['preset'] as List? ?? const [])) + PresetCell.fromJson(cell as Map), + ], + objectives: [ + for (final obj in json['objectives'] as List) + Objective.fromJson(obj as Map), + ], + stars: StarThresholds.fromJson(json['stars'] as Map), + generatorProfile: (json['generatorProfile'] as String?) ?? 'mid', + ); + + final String id; + final int seed; + final int moveLimit; + final List preset; + final List objectives; + final StarThresholds stars; + final String generatorProfile; + + GridState initialGrid() { + var grid = GridState.empty(); + for (final cell in preset) { + grid = grid.withCell( + cell.x, + cell.y, + Cell(cell.type, colorId: cell.colorId), + ); + } + return grid; + } + + Map toJson() => { + 'id': id, + 'seed': seed, + 'moveLimit': moveLimit, + 'preset': [for (final cell in preset) cell.toJson()], + 'objectives': [for (final obj in objectives) obj.toJson()], + 'stars': stars.toJson(), + 'generatorProfile': generatorProfile, + }; +} diff --git a/test/architecture_test.dart b/test/architecture_test.dart new file mode 100644 index 0000000..0fee8d4 --- /dev/null +++ b/test/architecture_test.dart @@ -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 = []; + 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'); + }); +} diff --git a/test/game/engine/game_engine_test.dart b/test/game/engine/game_engine_test.dart new file mode 100644 index 0000000..07c0842 --- /dev/null +++ b/test/game/engine/game_engine_test.dart @@ -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 preset = const [], + List> 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 _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 = {}; + 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 = [ + 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); + }); + }); +} diff --git a/test/game/engine/headless_stress_test.dart b/test/game/engine/headless_stress_test.dart new file mode 100644 index 0000000..aa08148 --- /dev/null +++ b/test/game/engine/headless_stress_test.dart @@ -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 >[], + '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'); + } + }); +} diff --git a/test/game/models/objective_test.dart b/test/game/models/objective_test.dart new file mode 100644 index 0000000..2d1e17a --- /dev/null +++ b/test/game/models/objective_test.dart @@ -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, + ); + }); + }); +} diff --git a/test/game/models/stage_test.dart b/test/game/models/stage_test.dart new file mode 100644 index 0000000..f069327 --- /dev/null +++ b/test/game/models/stage_test.dart @@ -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()); + 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); + }); + }); +}