9763968db9
Expose engine.rescueUsed getter and surface it through GameViewState so the result overlay can omit the watch-ad FilledButton after a rescue has been consumed, preventing a second tap from calling useContinue / addExtraMoves and hitting their StateError guard. Give-up is promoted to FilledButton when rescue is unavailable for clear affordance. Also emit stageStart / endlessStart analytics in restart() so every attempt (not just the first) is bracketed by a matching start event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
248 lines
8.0 KiB
Dart
248 lines
8.0 KiB
Dart
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);
|
|
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('rescueUsed getter', () {
|
|
test('rescueUsed flag is exposed and flips after a rescue', () {
|
|
final engine = GameEngine(
|
|
_stage(moveLimit: 1),
|
|
generator: _smallPool(1),
|
|
);
|
|
expect(engine.rescueUsed, isFalse);
|
|
engine.tryPlace(0, 0, 0);
|
|
expect(engine.phase, GamePhase.stuck);
|
|
expect(engine.rescueUsed, isFalse);
|
|
engine.addExtraMoves();
|
|
expect(engine.rescueUsed, isTrue);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|