diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b3b017b..7b9fe04 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -90,7 +90,10 @@ Tray generation algorithm (documented contract, tested): Quest stages may pin the FIRST tray via the stage's `tray` field (the "opening break" design — see CONTRIBUTING_QUESTS.md); the generator -takes over from the second tray on. +takes over from the second tray on. A score goal must exceed the +**opening ceiling** — `maxOpeningScore` (in `solvability.dart`) is the most +that opening tray can score alone, all-clear bonus included — so the +cascade can't win the stage outright; the validator enforces this. **Quest gem stages** (`GameState.gemGoal` non-empty): gems ride on the generated tray pieces rather than being pre-placed. After drawing a tray, @@ -126,7 +129,10 @@ Schema is versioned and strictly parsed. Details: `CONTRIBUTING_QUESTS.md`. One service (`lib/services/`); versioned JSON save model documented in `SAVE_MODEL.md`: settings, classic high score + resumable in-progress run (board, tray, RNG state, score, combo), quest progress, best combos, quest -cache metadata. +cache metadata. **Quest attempts are also resumable**: the in-progress +`GameState` plus its pack/stage/level ids are persisted, so leaving a stage +or killing the app resumes it exactly (the map reattaches pack/stage from +the catalog). The snapshot is cleared on win, loss, or restart. ## Key decisions diff --git a/CONTRIBUTING_QUESTS.md b/CONTRIBUTING_QUESTS.md index 44ae95f..a664262 100644 --- a/CONTRIBUTING_QUESTS.md +++ b/CONTRIBUTING_QUESTS.md @@ -83,22 +83,32 @@ toward what's reachable. ### Goals -- Score: `{ "type": "score", "target": 700 }` — reach the score. -- Gems: `{ "type": "gems", "counts": { "red": 8, "purple": 6 } }` — - collect *at least* the listed count of each color. Gems ride on the +- Score: `{ "type": "score", "target": 700 }` — reach the score. The + target **must sit above the opening ceiling**: the most the opening tray + can score on its own, *including the +300 all-clear bonus* if the opening + can empty the board. Otherwise the cascade wins the stage on move three + and it plays as a freebie. The validator computes this ceiling and + rejects any target at or below it, so a designed opening that all-clears + needs a target comfortably past ~400. Current packs ramp ~500 → ~1200. +- Gems: `{ "type": "gems", "counts": { "red": 5, "purple": 5, "blue": 5 } }` + — collect *at least* the listed count of each color. Gems ride on the generated tray pieces (spawned only for colors you still need), so the goal does NOT depend on gems being pre-placed on the board — you don't put gems in the board at all for a gem goal, and counts can be large. Collecting more than the goal is fine. Once a color is fully collected - it stops appearing on new pieces. + it stops appearing on new pieces. **Prefer multi-color goals** — aim for + **3 to 5 gem types**, even in early stages; a single-color gem goal reads + as flat. Counts can grow aggressively as the pack progresses. ### Rules the validator enforces - Boards are exactly 8×8 with only the characters above. - No row or column may start out already complete. - At least 20 empty cells (otherwise the stage is likely unplayable). -- Gem goals must be satisfiable by the gems on the board. -- Score targets between 50 and 20000. +- Gem goal counts are each between 1 and 60 (gems come from the tray, so + the board doesn't need to hold them). +- Score targets between 50 and 20000, **and above the opening ceiling** + (the opening tray can't win the stage by itself — all-clear included). - Stage ids unique within a pack; pack ids unique in the manifest. - The manifest checksum must match the pack file. diff --git a/PROGRESS.md b/PROGRESS.md index 19574c9..9029732 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -172,6 +172,24 @@ lose-screen/80%-banner visual eyeballing during play, a CI workflow - Session ended mid slice-D device verification at user request; all code committed on `dev`, working tree clean, 43/43 tests green. +### 2026-07-16 — Playtest: quest resume, gem tiles, difficulty tuning +- Quest attempts now resume: SaveData carries questRun (GameState) + + pack/stage/level ids; controller persists on start/each move, clears on + win/loss; quit keeps the snapshot; the map's _play resumes it if the + saved run matches the tapped node. Leaving a stage or app-kill no longer + loses progress (parity with classic). +- Gem-bearing blocks always draw on the neutral light tile — in the tray + and once placed — so the gem never fights the piece color (before, only + pre-placed gems were light). +- Difficulty (user feedback "too easy"): score targets raised but floored + ABOVE each stage's opening ceiling. Added solvability.maxOpeningScore + (best score the opening tray can make, all-clear +300 included) and a + validator rule rejecting score targets ≤ that ceiling — four stages + (s01/s02/t01/d01) were literally winnable from the opening before. + New score ramps ~500→1200. Gem goals: every gem stage now asks for 3–5 + colors (was many single-color), counts grow more aggressively. Opening + cascade kept as-is per user; the higher goals carry the difficulty. + ### 2026-07-16 — Gems ride on tray pieces (quest gem stages reworked) - User: pre-placing all gems on the grid made stages swingy and capped goals low. Now gems spawn on generated tray pieces (assignGems, ~50% @@ -180,8 +198,8 @@ lose-screen/80%-banner visual eyeballing during play, a CI workflow A placed gem is collected when a line through it clears. - Model: GameState gains trayGems (per-slot cell→color) + gemGoal; placement.stampWithGems; engine threads gems on place/refill; the - controller passes the goal; board renders placed gems in the piece - color (pre-placed stay light); tray pieces show their gems. + controller passes the goal; board + tray render gems (gem tiles later + moved to the neutral light substrate everywhere — see 07-16 playtest). - Validator: dropped the "board must contain the goal's gems" rule (obsolete); added a gem-count sanity range. All 20 gem stages had their board gems stripped and goals raised (single 8 / two 6 / three 5). diff --git a/SAVE_MODEL.md b/SAVE_MODEL.md index f1d454d..bdee7c7 100644 --- a/SAVE_MODEL.md +++ b/SAVE_MODEL.md @@ -27,6 +27,10 @@ silently discarding progress. "allTimeBestCombo": 0, // best combo streak ever (Combo Master screen) "classicRun": null, // in-progress classic run, or null "classicRunSeed": "12345", // seed that run started from (string int64) + "questRun": null, // in-progress quest attempt (GameState), or absent + "questRunPackId": "starter", // which pack/stage/level the questRun belongs to + "questRunStageId": "s07", // (the stage isn't stored in GameState, so the + "questRunLevelNumber": 7, // map reattaches it from the catalog on resume) "questCompleted": { // packId -> sorted stage ids completed "starter": ["s01", "s02"] }, @@ -34,6 +38,13 @@ silently discarding progress. } ``` +The four `questRun*` keys are additive and only emitted while a quest +attempt is in progress; they are dropped together when the stage is won, +lost, or restarted from scratch. Like the classic run, `questRun` is a +`GameState` snapshot (board, tray, RNG, score, gems), so resuming needs no +replay — the map screen looks up the pack/stage by id from the loaded +catalog and hands the saved `GameState` back to the controller. + ### `classicRun` (GameState snapshot) ```jsonc diff --git a/content/quests/manifest.json b/content/quests/manifest.json index 567c865..a964620 100644 --- a/content/quests/manifest.json +++ b/content/quests/manifest.json @@ -6,21 +6,21 @@ "title": "First Steps", "release_date": "2026-07-01", "file": "packs/starter.json", - "checksum": "8c70a5bdbc7a9a059ef8f106e7d70e5a3dd45f7664f511cd989f5d17f4aae400" + "checksum": "8d02386d85cdf853868622b7c3ce3df82060410f260ce8869b8570f3be02bc45" }, { "id": "deep-dig", "title": "Deep Dig", "release_date": "2026-07-05", "file": "packs/deep-dig.json", - "checksum": "eefa0748debb175ecb710141bb39e4b207fa3850162ad0b746b496c0615690b7" + "checksum": "11ba96f5ead3b4b6de075b3b766f599139cb2eb63e2b71e24a994e6ef9cf9548" }, { "id": "treasure-trail", "title": "Treasure Trail", "release_date": "2026-07-26", "file": "packs/treasure-trail.json", - "checksum": "c4e1c2d4edd1831459d9b263a5eb91739d50a3fc9eca2daa67af5553115af2b9" + "checksum": "42d90c5ea0f4ea479cb73eef3cc2b0513862f9241a44d88ddbcfbdbbf9b84fec" } ] } diff --git a/content/quests/packs/deep-dig.json b/content/quests/packs/deep-dig.json index 919969f..7467d50 100644 --- a/content/quests/packs/deep-dig.json +++ b/content/quests/packs/deep-dig.json @@ -17,7 +17,7 @@ ], "goal": { "type": "score", - "target": 200 + "target": 550 }, "tray": [ "rect2x3", @@ -40,7 +40,9 @@ "goal": { "type": "gems", "counts": { - "blue": 8 + "blue": 4, + "red": 4, + "yellow": 4 } }, "tray": [ @@ -63,7 +65,7 @@ ], "goal": { "type": "score", - "target": 300 + "target": 650 }, "tray": [ "tRight", @@ -86,8 +88,9 @@ "goal": { "type": "gems", "counts": { - "green": 6, - "red": 6 + "green": 5, + "red": 5, + "purple": 5 } }, "tray": [ @@ -110,7 +113,7 @@ ], "goal": { "type": "score", - "target": 400 + "target": 750 }, "tray": [ "line5h", @@ -133,7 +136,9 @@ "goal": { "type": "gems", "counts": { - "purple": 8 + "purple": 5, + "blue": 5, + "green": 5 } }, "tray": [ @@ -156,7 +161,7 @@ ], "goal": { "type": "score", - "target": 500 + "target": 850 }, "tray": [ "line3v", @@ -179,7 +184,10 @@ "goal": { "type": "gems", "counts": { - "yellow": 8 + "yellow": 6, + "red": 6, + "blue": 6, + "green": 6 } }, "hard": true, @@ -203,7 +211,7 @@ ], "goal": { "type": "score", - "target": 600 + "target": 950 }, "tray": [ "tLeft", @@ -226,8 +234,10 @@ "goal": { "type": "gems", "counts": { - "red": 6, - "blue": 6 + "red": 5, + "blue": 5, + "purple": 5, + "yellow": 5 } }, "tray": [ @@ -250,7 +260,7 @@ ], "goal": { "type": "score", - "target": 700 + "target": 1050 }, "hard": true, "tray": [ @@ -275,7 +285,9 @@ "type": "gems", "counts": { "purple": 6, - "green": 6 + "green": 6, + "red": 6, + "yellow": 6 } }, "hard": true, @@ -299,7 +311,7 @@ ], "goal": { "type": "score", - "target": 800 + "target": 1200 }, "hard": true, "tray": [ @@ -323,7 +335,11 @@ "goal": { "type": "gems", "counts": { - "red": 8 + "red": 6, + "blue": 6, + "purple": 6, + "yellow": 6, + "green": 6 } }, "hard": true, @@ -348,11 +364,11 @@ "goal": { "type": "gems", "counts": { - "red": 5, - "blue": 5, - "purple": 5, - "yellow": 5, - "green": 5 + "red": 6, + "blue": 6, + "purple": 6, + "yellow": 6, + "green": 6 } }, "hard": true, diff --git a/content/quests/packs/starter.json b/content/quests/packs/starter.json index 5d0285e..8343fec 100644 --- a/content/quests/packs/starter.json +++ b/content/quests/packs/starter.json @@ -17,7 +17,7 @@ ], "goal": { "type": "score", - "target": 150 + "target": 500 }, "tray": [ "line3h", @@ -39,7 +39,7 @@ ], "goal": { "type": "score", - "target": 250 + "target": 600 }, "tray": [ "line2h", @@ -62,7 +62,9 @@ "goal": { "type": "gems", "counts": { - "red": 8 + "red": 4, + "blue": 4, + "green": 4 } }, "seed": 7, @@ -86,7 +88,7 @@ ], "goal": { "type": "score", - "target": 350 + "target": 650 }, "tray": [ "tDown", @@ -109,7 +111,9 @@ "goal": { "type": "gems", "counts": { - "purple": 8 + "purple": 4, + "yellow": 4, + "red": 4 } }, "tray": [ @@ -132,7 +136,7 @@ ], "goal": { "type": "score", - "target": 450 + "target": 750 }, "tray": [ "sH", @@ -155,8 +159,9 @@ "goal": { "type": "gems", "counts": { - "red": 6, - "purple": 6 + "red": 5, + "purple": 5, + "blue": 5 } }, "tray": [ @@ -179,7 +184,7 @@ ], "goal": { "type": "score", - "target": 500 + "target": 850 }, "hard": true, "tray": [ @@ -203,7 +208,9 @@ "goal": { "type": "gems", "counts": { - "blue": 8 + "blue": 5, + "green": 5, + "yellow": 5 } }, "tray": [ @@ -226,7 +233,7 @@ ], "goal": { "type": "score", - "target": 600 + "target": 950 }, "tray": [ "corner3se", @@ -249,8 +256,10 @@ "goal": { "type": "gems", "counts": { - "green": 6, - "red": 6 + "green": 5, + "red": 5, + "blue": 5, + "purple": 5 } }, "tray": [ @@ -273,7 +282,7 @@ ], "goal": { "type": "score", - "target": 700 + "target": 1050 }, "hard": true, "tray": [ @@ -297,7 +306,10 @@ "goal": { "type": "gems", "counts": { - "purple": 8 + "purple": 6, + "yellow": 6, + "red": 6, + "green": 6 } }, "seed": 99, @@ -322,7 +334,7 @@ ], "goal": { "type": "score", - "target": 800 + "target": 1200 }, "tray": [ "corner5sw", @@ -347,7 +359,9 @@ "counts": { "red": 5, "purple": 5, - "yellow": 5 + "yellow": 5, + "blue": 5, + "green": 5 } }, "hard": true, diff --git a/content/quests/packs/treasure-trail.json b/content/quests/packs/treasure-trail.json index 0d8f507..d57c930 100644 --- a/content/quests/packs/treasure-trail.json +++ b/content/quests/packs/treasure-trail.json @@ -17,7 +17,7 @@ ], "goal": { "type": "score", - "target": 250 + "target": 550 }, "tray": [ "line3h", @@ -40,8 +40,9 @@ "goal": { "type": "gems", "counts": { - "red": 6, - "blue": 6 + "red": 5, + "blue": 5, + "green": 5 } }, "tray": [ @@ -64,7 +65,7 @@ ], "goal": { "type": "score", - "target": 350 + "target": 700 }, "tray": [ "tUp", @@ -87,7 +88,9 @@ "goal": { "type": "gems", "counts": { - "purple": 8 + "purple": 5, + "yellow": 5, + "red": 5 } }, "tray": [ @@ -110,7 +113,7 @@ ], "goal": { "type": "score", - "target": 450 + "target": 850 }, "tray": [ "corner5nw", @@ -133,8 +136,10 @@ "goal": { "type": "gems", "counts": { - "yellow": 6, - "green": 6 + "yellow": 5, + "green": 5, + "blue": 5, + "red": 5 } }, "tray": [ @@ -157,7 +162,7 @@ ], "goal": { "type": "score", - "target": 550 + "target": 1000 }, "hard": true, "tray": [ @@ -181,7 +186,10 @@ "goal": { "type": "gems", "counts": { - "blue": 8 + "blue": 6, + "purple": 6, + "red": 6, + "green": 6 } }, "hard": true, @@ -205,7 +213,7 @@ ], "goal": { "type": "score", - "target": 650 + "target": 1200 }, "hard": true, "tray": [ @@ -230,7 +238,10 @@ "type": "gems", "counts": { "red": 6, - "purple": 6 + "purple": 6, + "blue": 6, + "yellow": 6, + "green": 6 } }, "hard": true, diff --git a/lib/game/board_component.dart b/lib/game/board_component.dart index b5d1433..306407f 100644 --- a/lib/game/board_component.dart +++ b/lib/game/board_component.dart @@ -98,15 +98,10 @@ class BoardComponent extends PositionComponent Paint()..color = theme.emptyCell, ); } else if (occupant.gem != null) { - // Pre-placed gem tiles are the light substrate; a gem the player - // dropped keeps its piece color. - paintBlock( - canvas, - rect, - occupant.preplaced - ? puzzleBlockLight - : theme.blockColor(occupant.colorId), - ); + // Any gem-bearing block sits on the neutral light tile — whether + // pre-placed or dropped by the player — so the gem always reads + // the same and never fights the piece color. + paintBlock(canvas, rect, puzzleBlockLight); _paintGem(canvas, rect, gemColors[occupant.gem]!); } else if (occupant.preplaced) { // Pre-placed puzzle blocks share the neutral light tile. diff --git a/lib/game/tray_piece_component.dart b/lib/game/tray_piece_component.dart index f1e6b99..9ce4786 100644 --- a/lib/game/tray_piece_component.dart +++ b/lib/game/tray_piece_component.dart @@ -55,8 +55,10 @@ class TrayPieceComponent extends PositionComponent cellSide, cellSide, ); - paintBlock(canvas, rect, color); final gem = gems[i]; + // Gem-bearing cells use the neutral light tile so the gem reads the + // same in the tray as it does once placed on the board. + paintBlock(canvas, rect, gem != null ? puzzleBlockLight : color); if (gem != null) paintGemStar(canvas, rect, gemColors[gem]!); } } diff --git a/lib/models/save_data.dart b/lib/models/save_data.dart index d84165a..dc00e21 100644 --- a/lib/models/save_data.dart +++ b/lib/models/save_data.dart @@ -41,6 +41,10 @@ class SaveData { this.allTimeBestCombo = 0, this.classicRun, this.classicRunSeed, + this.questRun, + this.questRunPackId, + this.questRunStageId, + this.questRunLevelNumber, this.questCompleted = const {}, this.lastQuestFetchEpochMs, }); @@ -57,6 +61,14 @@ class SaveData { /// Seed the resumable run started from (diagnostics / bug reproduction). final int? classicRunSeed; + /// In-progress quest attempt to resume after leaving the stage or an app + /// kill; null when none. Reconstructing the full [QuestRun] needs the + /// pack/stage from the catalog, so we also store the ids and level. + final GameState? questRun; + final String? questRunPackId; + final String? questRunStageId; + final int? questRunLevelNumber; + /// Completed quest stage ids, keyed by pack id. final Map> questCompleted; @@ -70,6 +82,11 @@ class SaveData { GameState? classicRun, bool clearClassicRun = false, int? classicRunSeed, + GameState? questRun, + String? questRunPackId, + String? questRunStageId, + int? questRunLevelNumber, + bool clearQuestRun = false, Map>? questCompleted, int? lastQuestFetchEpochMs, }) { @@ -81,6 +98,16 @@ class SaveData { classicRunSeed: clearClassicRun ? null : (classicRunSeed ?? this.classicRunSeed), + questRun: clearQuestRun ? null : (questRun ?? this.questRun), + questRunPackId: clearQuestRun + ? null + : (questRunPackId ?? this.questRunPackId), + questRunStageId: clearQuestRun + ? null + : (questRunStageId ?? this.questRunStageId), + questRunLevelNumber: clearQuestRun + ? null + : (questRunLevelNumber ?? this.questRunLevelNumber), questCompleted: questCompleted ?? this.questCompleted, lastQuestFetchEpochMs: lastQuestFetchEpochMs ?? this.lastQuestFetchEpochMs, @@ -94,6 +121,12 @@ class SaveData { 'allTimeBestCombo': allTimeBestCombo, 'classicRun': classicRun?.toJson(), 'classicRunSeed': classicRunSeed?.toString(), + if (questRun != null) ...{ + 'questRun': questRun!.toJson(), + 'questRunPackId': questRunPackId, + 'questRunStageId': questRunStageId, + 'questRunLevelNumber': questRunLevelNumber, + }, 'questCompleted': questCompleted.map( (packId, stages) => MapEntry(packId, stages.toList()..sort()), ), @@ -109,6 +142,7 @@ class SaveData { } final run = json['classicRun'] as Map?; final seed = json['classicRunSeed'] as String?; + final questRun = json['questRun'] as Map?; return SaveData( settings: Settings.fromJson( (json['settings'] as Map? ?? const {}).cast(), @@ -119,6 +153,12 @@ class SaveData { ? null : GameState.fromJson(run.cast()), classicRunSeed: seed == null ? null : int.parse(seed), + questRun: questRun == null + ? null + : GameState.fromJson(questRun.cast()), + questRunPackId: json['questRunPackId'] as String?, + questRunStageId: json['questRunStageId'] as String?, + questRunLevelNumber: json['questRunLevelNumber'] as int?, questCompleted: { for (final entry in (json['questCompleted'] as Map? ?? const {}) diff --git a/lib/services/quest_validation.dart b/lib/services/quest_validation.dart index 61a10e3..c2a8607 100644 --- a/lib/services/quest_validation.dart +++ b/lib/services/quest_validation.dart @@ -51,6 +51,21 @@ List validatePack(QuestPack pack) { if (target < 50 || target > 20000) { problems.add('$where: score target $target out of sane range'); } + // A score stage must not be winnable by the opening tray alone. If + // the opening can all-clear, that +300 is included in the ceiling — + // the target has to sit above it so real play is required. + final tray = stage.tray; + if (tray != null) { + final pieces = [for (final id in tray) pieceById[id]!]; + final ceiling = maxOpeningScore(stage.board, pieces); + if (target <= ceiling) { + problems.add( + '$where: score target $target ≤ opening ceiling $ceiling — the ' + 'opening cascade (all-clear bonus included) would win the stage; ' + 'raise the target above $ceiling', + ); + } + } } // Opening-cascade contract (strengthened 2026-07-14 after playtest diff --git a/lib/state/quest_game_controller.dart b/lib/state/quest_game_controller.dart index f9d37d8..5182de6 100644 --- a/lib/state/quest_game_controller.dart +++ b/lib/state/quest_game_controller.dart @@ -9,8 +9,9 @@ import 'providers.dart'; enum QuestStatus { playing, won, lost } -/// One quest attempt. Unlike Classic, attempts are not persisted — a -/// closed stage restarts from its layout (reference behavior). +/// One quest attempt. The in-progress state is persisted (see +/// [SaveDataNotifier.storeQuestRun]) so leaving the stage or killing the +/// app resumes exactly where the player left off. class QuestRun { const QuestRun({ required this.pack, @@ -72,7 +73,7 @@ class QuestGameController extends Notifier { GemsGoal(counts: final c) => c, ScoreGoal() => const {}, }; - state = QuestRun( + final run = QuestRun( pack: pack, stage: stage, levelNumber: levelNumber, @@ -84,6 +85,26 @@ class QuestGameController extends Notifier { ), status: QuestStatus.playing, ); + state = run; + _persist(run); + } + + /// Resumes a persisted in-progress attempt: the saved [game] state on the + /// stage it belongs to. Pack/stage come from the catalog (the caller has + /// them) since they aren't in the save. + void resume( + QuestPack pack, + QuestStage stage, { + required int levelNumber, + required GameState game, + }) { + state = QuestRun( + pack: pack, + stage: stage, + levelNumber: levelNumber, + game: game, + status: QuestStatus.playing, + ); } void retry() { @@ -93,6 +114,15 @@ class QuestGameController extends Notifier { } } + void _persist(QuestRun run) => ref + .read(saveDataProvider.notifier) + .storeQuestRun( + run.game, + packId: run.pack.id, + stageId: run.stage.id, + levelNumber: run.levelNumber, + ); + /// Win beats lose when the goal lands on the final possible move. PlacementOutcome? place(int trayIndex, int row, int col) { final run = state; @@ -101,18 +131,29 @@ class QuestGameController extends Notifier { if (outcome == null) return null; var status = QuestStatus.playing; + final save = ref.read(saveDataProvider.notifier); if (goalMet(run.stage.goal, outcome.state)) { status = QuestStatus.won; - ref - .read(saveDataProvider.notifier) - .markStageCompleted(run.pack.id, run.stage.id); + save + ..markStageCompleted(run.pack.id, run.stage.id) + ..clearQuestRun(); } else if (GameEngine.isGameOver(outcome.state)) { status = QuestStatus.lost; + save.clearQuestRun(); + } else { + save.storeQuestRun( + outcome.state, + packId: run.pack.id, + stageId: run.stage.id, + levelNumber: run.levelNumber, + ); } state = run.copyWith(game: outcome.state, status: status); return outcome; } + /// Leaving the stage mid-play keeps the persisted snapshot so the map can + /// resume it; only the in-memory run is dropped. void quit() => state = null; /// Injects an arbitrary run state to set up deterministic tests. diff --git a/lib/state/save_data_notifier.dart b/lib/state/save_data_notifier.dart index fef7023..70cde8e 100644 --- a/lib/state/save_data_notifier.dart +++ b/lib/state/save_data_notifier.dart @@ -41,6 +41,26 @@ class SaveDataNotifier extends Notifier { ); } + /// Persists the in-progress quest attempt so leaving the stage (or an + /// app kill) resumes it exactly where it was. + void storeQuestRun( + GameState run, { + required String packId, + required String stageId, + required int levelNumber, + }) => _update( + state.copyWith( + questRun: run, + questRunPackId: packId, + questRunStageId: stageId, + questRunLevelNumber: levelNumber, + ), + ); + + /// Drops the resumable quest snapshot (stage won, lost, or abandoned via + /// retry from scratch). + void clearQuestRun() => _update(state.copyWith(clearQuestRun: true)); + /// Stamps the quest-fetch throttle (see questCatalogProvider). void markQuestFetch(int epochMs) => _update(state.copyWith(lastQuestFetchEpochMs: epochMs)); diff --git a/lib/systems/solvability.dart b/lib/systems/solvability.dart index 2275f04..aa76109 100644 --- a/lib/systems/solvability.dart +++ b/lib/systems/solvability.dart @@ -8,6 +8,7 @@ import '../models/piece.dart'; import 'game_constants.dart'; import 'line_clear.dart'; import 'placement.dart'; +import 'scoring.dart'; /// True if some position of [piece] on [board] completes ≥1 line. bool canClearLineWith(Board board, Piece piece) { @@ -145,6 +146,44 @@ bool openingCascadeExists(Board board, List pieces) { return search(board, pieces); } +/// Maximum score obtainable by placing [pieces] as an opening on [board], +/// searched over every order and position, with clears, combo, and the +/// all-clear bonus scored exactly as in play. This is the "just cash the +/// opening" ceiling: if the designed opening can empty the board, the +/// +[allClearBonus] is baked in here. Score goals must sit ABOVE this so a +/// stage can't be won by the opening cascade alone. Cheap — the opening is +/// only three pieces. +int maxOpeningScore(Board board, List pieces) { + var best = 0; + void search(Board b, List rest, int combo, int score) { + if (score > best) best = score; + if (rest.isEmpty) return; + final triedIds = {}; + for (var i = 0; i < rest.length; i++) { + final piece = rest[i]; + if (!triedIds.add(piece.id)) continue; + final remaining = [...rest]..removeAt(i); + for (var row = 0; row <= Board.size - piece.height; row++) { + for (var col = 0; col <= Board.size - piece.width; col++) { + if (!canPlace(b, piece, row, col)) continue; + final result = clearFullLines(stamp(b, piece, row, col)); + final broke = result.lineCount > 0; + final newCombo = broke ? combo + 1 : 0; + var delta = piece.cells.length * pointsPerCell; + if (broke) { + delta += lineScore(result.lineCount) + comboBonus(newCombo); + if (result.board.isEmpty) delta += allClearBonus; + } + search(result.board, remaining, newCombo, score + delta); + } + } + } + } + + search(board, pieces, 0, 0); + return best; +} + /// True if the [pieces] can ALL be placed in some order (clears happen /// between placements, exactly as in play). Backtracking with a node cap: /// if the search budget runs out undecided, assume playable — a rare diff --git a/lib/ui/quest_map_screen.dart b/lib/ui/quest_map_screen.dart index 17b2715..4fbec18 100644 --- a/lib/ui/quest_map_screen.dart +++ b/lib/ui/quest_map_screen.dart @@ -288,9 +288,22 @@ class _QuestMapScreenState extends ConsumerState } void _play(BuildContext context, _LevelNode node) { - ref - .read(questGameProvider.notifier) - .start(node.pack, node.stage, levelNumber: node.number); + final controller = ref.read(questGameProvider.notifier); + final save = ref.read(saveDataProvider); + // Resume a persisted in-progress attempt of this exact stage; otherwise + // start it fresh from its layout. + if (save.questRun != null && + save.questRunPackId == node.pack.id && + save.questRunStageId == node.stage.id) { + controller.resume( + node.pack, + node.stage, + levelNumber: node.number, + game: save.questRun!, + ); + } else { + controller.start(node.pack, node.stage, levelNumber: node.number); + } Navigator.push( context, MaterialPageRoute(builder: (_) => const QuestScreen()), diff --git a/test/quest_game_controller_test.dart b/test/quest_game_controller_test.dart index 066233e..e3b4a65 100644 --- a/test/quest_game_controller_test.dart +++ b/test/quest_game_controller_test.dart @@ -156,11 +156,76 @@ void main() { expect(run.game.board.at(7, 7), isNull); }); - test('quit clears the run', () async { + test('quit drops the in-memory run but keeps the saved snapshot', () async { final container = await makeContainer(); final controller = container.read(questGameProvider.notifier); controller.start(packWith(scoreStage), scoreStage, levelNumber: 1); controller.quit(); expect(container.read(questGameProvider), isNull); + // The persisted attempt survives so the map can resume it later. + expect(container.read(saveDataProvider).questRun, isNotNull); + expect(container.read(saveDataProvider).questRunStageId, 's1'); + }); + + test( + 'starting persists the attempt; leaving then resuming restores it', + () async { + final container = await makeContainer(); + final controller = container.read(questGameProvider.notifier); + controller.start(packWith(scoreStage), scoreStage, levelNumber: 3); + + var saved = container.read(saveDataProvider); + expect(saved.questRun, isNotNull); + expect(saved.questRunPackId, 'test'); + expect(saved.questRunStageId, 's1'); + expect(saved.questRunLevelNumber, 3); + + // A non-clearing placement keeps the stage playing and updates the save. + controller.debugLoadRun( + container + .read(questGameProvider)! + .copyWith( + game: container + .read(questGameProvider)! + .game + .copyWith(tray: ['single', 'single', 'single']), + ), + ); + controller.place(0, 0, 7); // fills (0,7), completes no line + saved = container.read(saveDataProvider); + expect(saved.questRun!.score, 1); + + // Leave, then resume from the saved snapshot. + final snapshot = saved.questRun!; + controller.quit(); + controller.resume( + packWith(scoreStage), + scoreStage, + levelNumber: 3, + game: snapshot, + ); + final run = container.read(questGameProvider)!; + expect(run.status, QuestStatus.playing); + expect(run.game.score, 1); + }, + ); + + test('winning clears the persisted run', () async { + final container = await makeContainer(); + final controller = container.read(questGameProvider.notifier); + controller.start(packWith(scoreStage), scoreStage, levelNumber: 1); + controller.debugLoadRun( + container + .read(questGameProvider)! + .copyWith( + game: container + .read(questGameProvider)! + .game + .copyWith(tray: ['single', 'single', 'single']), + ), + ); + controller.place(0, 7, 7); // completes row 7, reaches target 11 + expect(container.read(questGameProvider)!.status, QuestStatus.won); + expect(container.read(saveDataProvider).questRun, isNull); }); } diff --git a/test/quest_parser_test.dart b/test/quest_parser_test.dart index f1a4344..75cafa2 100644 --- a/test/quest_parser_test.dart +++ b/test/quest_parser_test.dart @@ -187,12 +187,17 @@ void main() { }); test('validator demands a full opening cascade', () { + // High score target so the opening-ceiling rule never fires — this + // test isolates the cascade contract. QuestPack packWith(List board, List tray) => QuestPack.fromJson({ 'schema': 1, 'id': 'p', 'title': 'P', - 'stages': [stageJson(board: board)..['tray'] = tray], + 'stages': [ + stageJson(board: board, goal: {'type': 'score', 'target': 5000}) + ..['tray'] = tray, + ], }); // Empty board: nothing can break on move one. @@ -244,6 +249,34 @@ void main() { isEmpty, ); }); + + test('score target must sit above the opening ceiling', () { + // The classic s01 layout: the opening can all-clear (+300), so a low + // target is rejected while a high one passes. + const board = [ + '........', + '........', + '........', + '........', + '........', + '11...111', + '000..000', + '3333.333', + ]; + const tray = ['line3h', 'line2h', 'single']; + QuestPack withTarget(int target) => QuestPack.fromJson({ + 'schema': 1, + 'id': 'p', + 'title': 'P', + 'stages': [ + stageJson(board: board, goal: {'type': 'score', 'target': target}) + ..['tray'] = tray, + ], + }); + + expect(validatePack(withTarget(200)).single, contains('opening ceiling')); + expect(validatePack(withTarget(500)), isEmpty); + }); }); group('goalDescription', () { diff --git a/test/save_data_test.dart b/test/save_data_test.dart index 1c7aca3..d5bf5ac 100644 --- a/test/save_data_test.dart +++ b/test/save_data_test.dart @@ -100,6 +100,49 @@ void main() { expect(restored.gemGoal, {GemColor.red: 5, GemColor.blue: 3}); }); + test('an in-progress quest run survives a JSON round trip', () { + final questRun = GameEngine.newGame( + 42, + gemGoal: {GemColor.red: 5, GemColor.blue: 5, GemColor.green: 5}, + ).copyWith(score: 120, gemsCollected: {GemColor.red: 2}); + + final data = SaveData( + questRun: questRun, + questRunPackId: 'starter', + questRunStageId: 's07', + questRunLevelNumber: 7, + ); + final restored = roundTrip(data); + + expect(restored.questRunPackId, 'starter'); + expect(restored.questRunStageId, 's07'); + expect(restored.questRunLevelNumber, 7); + expect(restored.questRun, isNotNull); + expect(restored.questRun!.score, 120); + expect(restored.questRun!.gemGoal, { + GemColor.red: 5, + GemColor.blue: 5, + GemColor.green: 5, + }); + expect(restored.questRun!.gemsCollected, {GemColor.red: 2}); + }); + + test('clearing the quest run drops all of its fields', () { + final data = SaveData( + questRun: GameEngine.newGame(1), + questRunPackId: 'starter', + questRunStageId: 's01', + questRunLevelNumber: 1, + ); + final cleared = data.copyWith(clearQuestRun: true); + expect(cleared.questRun, isNull); + expect(cleared.questRunPackId, isNull); + expect(cleared.questRunStageId, isNull); + expect(cleared.questRunLevelNumber, isNull); + // And a run-free save omits the keys entirely. + expect(roundTrip(cleared).questRun, isNull); + }); + test('unknown save version throws instead of silently loading', () { expect( () => SaveData.fromJson(const {'version': 999}),