fix(boosters): count hammer/line-bomb clears toward objectives and win
Owner playtesting found that hammering a gem (or line-bombing a gem line) removed it visually but did not satisfy the clear-gems objective and never completed the stage — it felt broken. Per owner decision, booster clears now count: a hammered gem emits gems:1, a line-bomb emits lines:1 + the line's gem count, both folded through the objectives. After any booster the engine resolves the phase (completed objective -> won, else re-check stuck), which was the direct cause of the stage not finishing. Score and the move counter remain untouched. Reverses the earlier no-objective-credit rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user