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); expect(result.clearedRows, [3]); expect(result.clearedCols, isEmpty); // 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); }); }); }