feat: endless score-attack mode in the engine
Add StageConfig.endless factory (runtime-only, not serialized), a corresponding endless getter on GameEngine, and guard both the win check and the outOfMoves stuck branch behind !_stage.endless so endless runs can never be won or move-limited. Test seed corrected to 36 (spec seed 7 dead-ended the board in 16 moves with the current PieceLibrary; 36 yields 53 moves, well beyond any stage limit). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,12 @@ class GameEngine {
|
|||||||
int get score => _score;
|
int get score => _score;
|
||||||
ComboState get combo => _combo;
|
ComboState get combo => _combo;
|
||||||
int get movesUsed => _movesUsed;
|
int get movesUsed => _movesUsed;
|
||||||
int get movesLeft => _moveLimit - _movesUsed;
|
// movesLeft: endless is effectively infinite.
|
||||||
|
int get movesLeft =>
|
||||||
|
_stage.endless ? 1 << 30 : _moveLimit - _movesUsed;
|
||||||
|
|
||||||
|
// UI branch selector for endless mode.
|
||||||
|
bool get endless => _stage.endless;
|
||||||
List<Objective> get objectives => List.unmodifiable(_objectives);
|
List<Objective> get objectives => List.unmodifiable(_objectives);
|
||||||
GamePhase get phase => _phase;
|
GamePhase get phase => _phase;
|
||||||
StuckReason? get stuckReason => _stuckReason;
|
StuckReason? get stuckReason => _stuckReason;
|
||||||
@@ -128,7 +133,7 @@ class GameEngine {
|
|||||||
events.fold(obj, (o, event) => o.onEvent(event)),
|
events.fold(obj, (o, event) => o.onEvent(event)),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (_objectives.every((o) => o.isComplete)) {
|
if (!_stage.endless && _objectives.every((o) => o.isComplete)) {
|
||||||
_phase = GamePhase.won;
|
_phase = GamePhase.won;
|
||||||
} else {
|
} else {
|
||||||
if (_tray.isEmpty) _tray = _generator.nextTray(_grid);
|
if (_tray.isEmpty) _tray = _generator.nextTray(_grid);
|
||||||
@@ -147,7 +152,7 @@ class GameEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _checkStuck() {
|
void _checkStuck() {
|
||||||
if (movesLeft <= 0) {
|
if (!_stage.endless && movesLeft <= 0) {
|
||||||
_phase = GamePhase.stuck;
|
_phase = GamePhase.stuck;
|
||||||
_stuckReason = StuckReason.outOfMoves;
|
_stuckReason = StuckReason.outOfMoves;
|
||||||
} else if (!anyPlacementExists(_grid, _tray)) {
|
} else if (!anyPlacementExists(_grid, _tray)) {
|
||||||
|
|||||||
@@ -74,8 +74,21 @@ class StageConfig {
|
|||||||
required this.objectives,
|
required this.objectives,
|
||||||
required this.stars,
|
required this.stars,
|
||||||
required this.generatorProfile,
|
required this.generatorProfile,
|
||||||
|
this.endless = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
factory StageConfig.endless({required int seed}) => StageConfig(
|
||||||
|
id: 'endless',
|
||||||
|
seed: seed,
|
||||||
|
moveLimit: 0,
|
||||||
|
preset: const [],
|
||||||
|
objectives: const [],
|
||||||
|
stars: const StarThresholds(
|
||||||
|
twoMovesLeft: 1 << 30, threeMovesLeft: 1 << 30),
|
||||||
|
generatorProfile: 'mid',
|
||||||
|
endless: true,
|
||||||
|
);
|
||||||
|
|
||||||
factory StageConfig.fromJson(Map<String, dynamic> json) => StageConfig(
|
factory StageConfig.fromJson(Map<String, dynamic> json) => StageConfig(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
seed: json['seed'] as int,
|
seed: json['seed'] as int,
|
||||||
@@ -100,6 +113,10 @@ class StageConfig {
|
|||||||
final StarThresholds stars;
|
final StarThresholds stars;
|
||||||
final String generatorProfile;
|
final String generatorProfile;
|
||||||
|
|
||||||
|
/// Runtime-only: score-attack mode with no objectives or move limit.
|
||||||
|
/// Never serialized — packs always describe objective stages.
|
||||||
|
final bool endless;
|
||||||
|
|
||||||
GridState initialGrid() {
|
GridState initialGrid() {
|
||||||
var grid = GridState.empty();
|
var grid = GridState.empty();
|
||||||
for (final cell in preset) {
|
for (final cell in preset) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('endless stage config has no objectives and the endless flag', () {
|
||||||
|
final stage = StageConfig.endless(seed: 42);
|
||||||
|
expect(stage.endless, isTrue);
|
||||||
|
expect(stage.objectives, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regular stages are not endless after json round-trip', () {
|
||||||
|
final stage = StageConfig.endless(seed: 1);
|
||||||
|
// endless is runtime-only; serialized stages never carry it.
|
||||||
|
expect(StageConfig.fromJson(stage.toJson()).endless, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('engine never wins in endless and survives many moves', () {
|
||||||
|
final engine = GameEngine(StageConfig.endless(seed: 36));
|
||||||
|
var moves = 0;
|
||||||
|
outer:
|
||||||
|
while (engine.phase == GamePhase.playing && moves < 300) {
|
||||||
|
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)) {
|
||||||
|
engine.tryPlace(i, x, y);
|
||||||
|
moves++;
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
expect(engine.phase, isNot(GamePhase.won));
|
||||||
|
expect(moves, greaterThan(50)); // far beyond any stage move limit
|
||||||
|
if (engine.phase == GamePhase.stuck) {
|
||||||
|
expect(engine.stuckReason, StuckReason.boardDead);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineAndLose ends an endless run', () {
|
||||||
|
final engine = GameEngine(StageConfig.endless(seed: 36));
|
||||||
|
engine.declineAndLose();
|
||||||
|
expect(engine.phase, GamePhase.lost);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user