From 6c76837ab62d53b3b117cdeef8bb109113838923 Mon Sep 17 00:00:00 2001 From: airkjw Date: Fri, 12 Jun 2026 06:53:02 +0900 Subject: [PATCH] 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 --- lib/game/engine/game_engine.dart | 11 +++++-- lib/game/models/stage.dart | 17 +++++++++++ test/game/engine/endless_test.dart | 49 ++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 test/game/engine/endless_test.dart diff --git a/lib/game/engine/game_engine.dart b/lib/game/engine/game_engine.dart index 678c80f..83f10d3 100644 --- a/lib/game/engine/game_engine.dart +++ b/lib/game/engine/game_engine.dart @@ -73,7 +73,12 @@ class GameEngine { int get score => _score; ComboState get combo => _combo; 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 get objectives => List.unmodifiable(_objectives); GamePhase get phase => _phase; StuckReason? get stuckReason => _stuckReason; @@ -128,7 +133,7 @@ class GameEngine { 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; } else { if (_tray.isEmpty) _tray = _generator.nextTray(_grid); @@ -147,7 +152,7 @@ class GameEngine { } void _checkStuck() { - if (movesLeft <= 0) { + if (!_stage.endless && movesLeft <= 0) { _phase = GamePhase.stuck; _stuckReason = StuckReason.outOfMoves; } else if (!anyPlacementExists(_grid, _tray)) { diff --git a/lib/game/models/stage.dart b/lib/game/models/stage.dart index c186785..ee0964f 100644 --- a/lib/game/models/stage.dart +++ b/lib/game/models/stage.dart @@ -74,8 +74,21 @@ class StageConfig { required this.objectives, required this.stars, 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 json) => StageConfig( id: json['id'] as String, seed: json['seed'] as int, @@ -100,6 +113,10 @@ class StageConfig { final StarThresholds stars; 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() { var grid = GridState.empty(); for (final cell in preset) { diff --git a/test/game/engine/endless_test.dart b/test/game/engine/endless_test.dart new file mode 100644 index 0000000..1c21d61 --- /dev/null +++ b/test/game/engine/endless_test.dart @@ -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); + }); +}