diff --git a/lib/game/engine/game_engine.dart b/lib/game/engine/game_engine.dart index bddb5e3..741c4cb 100644 --- a/lib/game/engine/game_engine.dart +++ b/lib/game/engine/game_engine.dart @@ -217,19 +217,22 @@ class GameEngine { _phase = GamePhase.lost; } - /// Booster: empties one filled cell. No move/score/combo/objective effect. - /// Allowed only mid-attempt (playing or stuck). Returns false on an empty - /// cell or finished attempt so the caller keeps the booster. + /// Booster: empties one filled cell. No move/score/combo effect, but a + /// hammered gem DOES count toward a clear-gems objective (and can win the + /// stage). Allowed only mid-attempt. Returns false on an empty cell or a + /// finished attempt so the caller keeps the booster. bool useHammer(int x, int y) { if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; if (!_grid.isOccupied(x, y)) return false; + final wasGem = _grid.cellAt(x, y).type == CellType.gem; _grid = _grid.withCell(x, y, const Cell(CellType.empty)); - _checkStuck(); + _resolveAfterBooster( + wasGem ? const LinesCleared(lines: 0, gems: 1) : null); return true; } - /// Booster: re-deals the tray. No move/score effect. Re-checks stuck so a - /// dead board with a hopeless tray can become playable again. + /// Booster: re-deals the tray. No move/score/objective effect. Re-checks + /// stuck so a dead board with a hopeless tray can become playable again. bool useShuffle() { if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; _tray = _generator.nextTray(_grid); @@ -237,17 +240,35 @@ class GameEngine { return true; } - /// Booster: empties one row or one column (exactly one of [row]/[col]). - /// No move/score/objective effect. Re-checks stuck. + /// Booster: empties one row or one column (exactly one of [row]/[col]). No + /// move/score effect, but it counts as clearing one line plus any gems on + /// that line, toward the stage objectives (and can win the stage). bool useLineBomb({int? row, int? col}) { if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; if ((row == null) == (col == null)) return false; // need exactly one + var gems = 0; for (var i = 0; i < GridState.size; i++) { final x = col ?? i; final y = row ?? i; + if (_grid.cellAt(x, y).type == CellType.gem) gems++; _grid = _grid.withCell(x, y, const Cell(CellType.empty)); } - _checkStuck(); + _resolveAfterBooster(LinesCleared(lines: 1, gems: gems)); return true; } + + /// After a booster mutates the grid, feed any [cleared] event through the + /// objectives so booster-cleared gems/lines count, then resolve the phase: + /// a completed objective wins the stage, otherwise re-check stuck. Score and + /// the move counter stay untouched — boosters are help, not a placement. + void _resolveAfterBooster(LinesCleared? cleared) { + if (cleared != null) { + _objectives = [for (final obj in _objectives) obj.onEvent(cleared)]; + } + if (!_stage.endless && _objectives.every((o) => o.isComplete)) { + _phase = GamePhase.won; + } else { + _checkStuck(); + } + } } diff --git a/test/game/engine/booster_test.dart b/test/game/engine/booster_test.dart index 7dd7923..e850bec 100644 --- a/test/game/engine/booster_test.dart +++ b/test/game/engine/booster_test.dart @@ -19,6 +19,56 @@ StageConfig _stage() => StageConfig.fromJson({ }, }); +const _stars = { + 'two': {'movesLeft': 5}, + 'three': {'movesLeft': 10}, +}; + +// Two gems plus one plain filled cell; objective is to clear both gems. +StageConfig _gemStage() => StageConfig.fromJson({ + 'id': 'b_gem', + 'seed': 1, + 'moveLimit': 20, + 'preset': [ + {'x': 0, 'y': 0, 't': 'gem'}, + {'x': 3, 'y': 3, 't': 'gem'}, + {'x': 5, 'y': 5, 't': 'filled', 'c': 2}, + ], + 'objectives': [ + {'type': 'clearGems', 'count': 2}, + ], + 'stars': _stars, + }); + +// Two gems sitting on row 2; objective is to clear both gems. +StageConfig _gemRowStage() => StageConfig.fromJson({ + 'id': 'b_gemrow', + 'seed': 1, + 'moveLimit': 20, + 'preset': [ + {'x': 1, 'y': 2, 't': 'gem'}, + {'x': 4, 'y': 2, 't': 'gem'}, + ], + 'objectives': [ + {'type': 'clearGems', 'count': 2}, + ], + 'stars': _stars, + }); + +// Objective is to clear one line. +StageConfig _lineStage() => StageConfig.fromJson({ + 'id': 'b_line', + 'seed': 1, + 'moveLimit': 20, + 'preset': [ + {'x': 0, 'y': 0, 't': 'filled', 'c': 1}, + ], + 'objectives': [ + {'type': 'clearLines', 'count': 1}, + ], + 'stars': _stars, + }); + void main() { test('useHammer empties a filled cell without scoring or spending a move', () { final e = GameEngine(_stage()); @@ -101,4 +151,43 @@ void main() { e.declineAndLose(); expect(e.useLineBomb(row: 0), isFalse); }); + + // --- Boosters count toward objectives (owner decision 2026-06-18) --- + + test('useHammer on a gem counts toward the gem objective and wins the stage ' + 'when it clears the last one', () { + final e = GameEngine(_gemStage()); // 2 gems, objective clearGems(2) + expect(e.objectives.first.current, 0); + + expect(e.useHammer(0, 0), isTrue); // first gem + expect(e.objectives.first.current, 1); + expect(e.phase, GamePhase.playing); + + expect(e.useHammer(3, 3), isTrue); // last gem -> objective complete + expect(e.objectives.first.current, 2); + expect(e.phase, GamePhase.won, reason: 'clearing the last gem wins'); + }); + + test('useHammer on a plain filled cell does not change the gem objective', () { + final e = GameEngine(_gemStage()); + expect(e.useHammer(5, 5), isTrue); // a non-gem filled cell + expect(e.objectives.first.current, 0); + expect(e.phase, GamePhase.playing); + }); + + test('useLineBomb counts the gems in the cleared line toward the objective', + () { + final e = GameEngine(_gemRowStage()); // 2 gems on row 2, clearGems(2) + expect(e.useLineBomb(row: 2), isTrue); + expect(e.objectives.first.current, 2); + expect(e.phase, GamePhase.won); + }); + + test('useLineBomb counts as a cleared line toward the line objective', () { + final e = GameEngine(_lineStage()); // clearLines(1) + expect(e.objectives.first.current, 0); + expect(e.useLineBomb(row: 0), isTrue); + expect(e.objectives.first.current, 1); + expect(e.phase, GamePhase.won); + }); }