Add objectives, stage config, and GameEngine session core
Sealed Objective types (clearGems/reachScore/clearLines) with JSON round-trip; StageConfig with preset cells and star thresholds; GameEngine orchestrating placement -> clear -> scoring -> objectives with stuck detection, one-shot rescue (continue / +5 moves), and deterministic per-attempt RNG. 100-game headless stress test and pure-Dart architecture guard. 76 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<GameEvent> 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<Piece> _tray;
|
||||||
|
List<Objective> _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<Piece> get tray => List.unmodifiable(_tray);
|
||||||
|
int get score => _score;
|
||||||
|
ComboState get combo => _combo;
|
||||||
|
int get movesUsed => _movesUsed;
|
||||||
|
int get movesLeft => _moveLimit - _movesUsed;
|
||||||
|
List<Objective> 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 = <GameEvent>[
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ class PieceGenerator {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final tray = <Piece>[if (largestFitting != null) largestFitting];
|
final tray = <Piece>[?largestFitting];
|
||||||
for (final p in bySizeDesc.reversed) {
|
for (final p in bySizeDesc.reversed) {
|
||||||
if (tray.length == traySize) break;
|
if (tray.length == traySize) break;
|
||||||
if (tray.any((t) => t.id == p.id)) continue;
|
if (tray.any((t) => t.id == p.id)) continue;
|
||||||
|
|||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {'type': 'clearLines', 'count': target};
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) =>
|
||||||
|
StarThresholds(
|
||||||
|
twoMovesLeft: (json['two'] as Map<String, dynamic>)['movesLeft'] as int,
|
||||||
|
threeMovesLeft:
|
||||||
|
(json['three'] as Map<String, dynamic>)['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<String, dynamic> 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<String, dynamic> 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<String, dynamic>),
|
||||||
|
],
|
||||||
|
objectives: [
|
||||||
|
for (final obj in json['objectives'] as List)
|
||||||
|
Objective.fromJson(obj as Map<String, dynamic>),
|
||||||
|
],
|
||||||
|
stars: StarThresholds.fromJson(json['stars'] as Map<String, dynamic>),
|
||||||
|
generatorProfile: (json['generatorProfile'] as String?) ?? 'mid',
|
||||||
|
);
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final int seed;
|
||||||
|
final int moveLimit;
|
||||||
|
final List<PresetCell> preset;
|
||||||
|
final List<Objective> 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<String, dynamic> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 = <String>[];
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<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);
|
||||||
|
// 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 = <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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 <Map<String, dynamic>>[],
|
||||||
|
'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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Objective>());
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user