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:
2026-06-11 13:11:31 +09:00
parent 0210c14858
commit 62cbb4b16a
10 changed files with 886 additions and 1 deletions
+88
View File
@@ -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};
}
+124
View File
@@ -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,
};
}