From a27e226b4d3a3e28a56fa761ce9e61b0ffef40ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=92=E1=85=A7=E1=86=AB=E1=84=86?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Thu, 16 Jul 2026 07:49:22 +0900 Subject: [PATCH] feat(quests): gems ride on generated tray pieces, not pre-placed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: pre-placing every gem on the grid made stages swingy and kept goals small. Now gems spawn on generated tray pieces — assignGems attaches gems (~50% of pieces, big pieces sometimes two) drawing only colors still needed; a fully-collected color stops appearing; a placed gem is collected when a line through it clears; over-collecting is fine. - Model: GameState.trayGems (per-slot cell->color) + gemGoal; placement.stampWithGems; engine threads gems through place/refill; quest controller passes the goal. - Render: placed gems keep the piece color (pre-placed stay light); tray pieces show their gems (shared paintGemStar). - Validator: dropped the obsolete 'board must contain the gems' rule, added a gem-count range. All 20 gem stages stripped of board gems, goals raised (single 8 / two 6 / three 5). Tests: engine gem collection + needed-only refill + over-collect; assignGems needed-only/deterministic; trayGems/gemGoal save round-trip. Co-Authored-By: Claude Fable 5 --- ARCHITECTURE.md | 8 ++ CONTRIBUTING_QUESTS.md | 18 +++-- PROGRESS.md | 16 ++++ SAVE_MODEL.md | 2 + content/quests/manifest.json | 6 +- content/quests/packs/deep-dig.json | 94 ++++++++++++------------ content/quests/packs/starter.json | 66 ++++++++--------- content/quests/packs/treasure-trail.json | 48 ++++++------ lib/game/block_painter.dart | 31 ++++++++ lib/game/board_component.dart | 10 ++- lib/game/tray_piece_component.dart | 16 ++-- lib/models/game_state.dart | 42 +++++++++++ lib/services/quest_validation.dart | 15 ++-- lib/state/quest_game_controller.dart | 8 ++ lib/systems/game_constants.dart | 8 ++ lib/systems/game_engine.dart | 75 +++++++++++++------ lib/systems/piece_generator.dart | 40 ++++++++++ lib/systems/placement.dart | 21 ++++++ test/game_engine_test.dart | 80 ++++++++++++++++++++ test/piece_generator_test.dart | 31 ++++++++ test/quest_parser_test.dart | 10 +-- test/save_data_test.dart | 22 ++++++ 22 files changed, 511 insertions(+), 156 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a6f5be..b3b017b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -92,6 +92,14 @@ 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. +**Quest gem stages** (`GameState.gemGoal` non-empty): gems ride on the +generated tray pieces rather than being pre-placed. After drawing a tray, +`assignGems` attaches gems to some pieces, drawing colors only from those +still needed (goal − collected); a color that's fully collected stops +spawning. A placed gem is collected when a line through it clears +(`GameState.trayGems` carries the pending gems per tray slot). Goals can +exceed what's ever on the board, and over-collecting is fine (win is ≥). + **Classic clear-focus** (`GameState.clearFocus`, set by the classic controller): the same generator, turned up for maximum clears/combos. Breakers are boosted ~9×, near-empty boards favor big/long pieces (to diff --git a/CONTRIBUTING_QUESTS.md b/CONTRIBUTING_QUESTS.md index fcb01c3..44ae95f 100644 --- a/CONTRIBUTING_QUESTS.md +++ b/CONTRIBUTING_QUESTS.md @@ -73,16 +73,24 @@ Piece ids are the entries in `lib/models/piece_catalog.dart` (`single`, |------|---------| | `.` | empty cell | | `0`–`7` | block in palette color 0–7 (red, green, orange, yellow, blue, purple, cyan, pink) | -| `r` `b` `p` `y` `g` | gold block holding a red / blue / purple / yellow / green **gem** | +| `r` `b` `p` `y` `g` | light tile holding a red / blue / purple / yellow / green **gem** | -Gems are collected when any completed line containing them clears. +Gems are collected when any completed line containing them clears. For a +gem GOAL you normally leave these out of the board entirely — gems come +from the tray (see Goals). The gem letters remain available if you want a +few decorative pre-placed gems, but they are not required and not counted +toward what's reachable. ### Goals - Score: `{ "type": "score", "target": 700 }` — reach the score. -- Gems: `{ "type": "gems", "counts": { "red": 2, "purple": 3 } }` — - collect all listed gems. Every color you require must actually exist on - the board in at least that quantity. +- Gems: `{ "type": "gems", "counts": { "red": 8, "purple": 6 } }` — + 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. ### Rules the validator enforces diff --git a/PROGRESS.md b/PROGRESS.md index b6d27df..19574c9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -172,6 +172,22 @@ 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 — 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% + of pieces carry one, big pieces sometimes two), drawing only colors + still needed; a met color stops spawning; over-collecting is fine. + 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. +- 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). +- Workflow: per user, land on dev only from here — no auto-promote to + main (they promote later). See memory no-auto-promote-to-main. + ### 2026-07-16 — Playtest round 5: 7 polish items - Ghost dial-back: nearest-legal snap capped to a 1-cell radius (catches grid-line straddling, no longer teleports onto far gaps; over occupied diff --git a/SAVE_MODEL.md b/SAVE_MODEL.md index 64bd8c2..f1d454d 100644 --- a/SAVE_MODEL.md +++ b/SAVE_MODEL.md @@ -46,6 +46,8 @@ silently discarding progress. "roundBestCombo": 7, "allClears": 1, // this run; absent in old saves -> 0 "clearFocus": true, // classic mode's easy-clears dealing + "gemGoal": {"red": 8}, // quest gem stage target (else absent) + "trayGems": [{"0": "red"}, {}, {}], // gems on each tray slot (else absent) "gems": {"red": 1} // collected this run (quest only) } ``` diff --git a/content/quests/manifest.json b/content/quests/manifest.json index ff7bf6a..567c865 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": "770475f32eaa716e132fb1de37d1c3cf8cfa53904d018101e77e92a8969ffdfa" + "checksum": "8c70a5bdbc7a9a059ef8f106e7d70e5a3dd45f7664f511cd989f5d17f4aae400" }, { "id": "deep-dig", "title": "Deep Dig", "release_date": "2026-07-05", "file": "packs/deep-dig.json", - "checksum": "8578afdf6304fd3fe8ca1281fb2b2387ab5de383b36b417278d2a437bc518a80" + "checksum": "eefa0748debb175ecb710141bb39e4b207fa3850162ad0b746b496c0615690b7" }, { "id": "treasure-trail", "title": "Treasure Trail", "release_date": "2026-07-26", "file": "packs/treasure-trail.json", - "checksum": "f0b78a9ea566c9b5af0a410e06fb4f4e40b58931536ba329de4764f8c2facc9f" + "checksum": "c4e1c2d4edd1831459d9b263a5eb91739d50a3fc9eca2daa67af5553115af2b9" } ] } diff --git a/content/quests/packs/deep-dig.json b/content/quests/packs/deep-dig.json index 89d0c16..919969f 100644 --- a/content/quests/packs/deep-dig.json +++ b/content/quests/packs/deep-dig.json @@ -29,18 +29,18 @@ "id": "d02", "board": [ "........", - "..b.....", + "..3.....", "........", "........", - "3b33.333", - "44..44b4", + "3333.333", + "44..4434", "44..4444", "111...11" ], "goal": { "type": "gems", "counts": { - "blue": 3 + "blue": 8 } }, "tray": [ @@ -75,19 +75,19 @@ "id": "d04", "board": [ "........", - ".g....r.", + ".3....3.", "........", "........", "3333.333", - "77g7.777", + "7737.777", "777..777", - "1r11...1" + "1311...1" ], "goal": { "type": "gems", "counts": { - "green": 2, - "red": 2 + "green": 6, + "red": 6 } }, "tray": [ @@ -121,19 +121,19 @@ { "id": "d06", "board": [ - "...p....", + "...3....", "........", "........", "000..000", "3333.333", - "5p5...55", - "55p...55", - "5p5...55" + "535...55", + "553...55", + "535...55" ], "goal": { "type": "gems", "counts": { - "purple": 4 + "purple": 8 } }, "tray": [ @@ -167,19 +167,19 @@ { "id": "d08", "board": [ - "y.......", + "3.......", "........", "........", "000..000", "3333.333", - "444.y444", + "444.3444", "444..444", - "4y44.444" + "4344.444" ], "goal": { "type": "gems", "counts": { - "yellow": 3 + "yellow": 8 } }, "hard": true, @@ -214,20 +214,20 @@ { "id": "d10", "board": [ - "..r..b..", + "..3..3..", "........", "........", "111...11", - "3r33.333", - "77..7r77", - "777.7b77", + "3333.333", + "77..7377", + "777.7377", "........" ], "goal": { "type": "gems", "counts": { - "red": 3, - "blue": 2 + "red": 6, + "blue": 6 } }, "tray": [ @@ -262,20 +262,20 @@ { "id": "d12", "board": [ - ".p....g.", + ".3....3.", "........", - "..g.....", + "..3.....", "111...11", - "3g33.333", - "5p55.555", + "3333.333", + "5355.555", "555..555", - "5p5.5555" + "535.5555" ], "goal": { "type": "gems", "counts": { - "purple": 3, - "green": 3 + "purple": 6, + "green": 6 } }, "hard": true, @@ -311,19 +311,19 @@ { "id": "d14", "board": [ - "r.......", + "3.......", "........", "........", "3333.333", - "7r..7777", - "7r.77777", - "00r00.00", - "0000r.00" + "73..7777", + "73.77777", + "00300.00", + "00003.00" ], "goal": { "type": "gems", "counts": { - "red": 5 + "red": 8 } }, "hard": true, @@ -336,23 +336,23 @@ { "id": "d15", "board": [ - "r.......", + "3.......", "........", "........", "3333.333", - "1b...111", - "111.g111", - "44y4..44", - "444..4p4" + "13...111", + "111.3111", + "4434..44", + "444..434" ], "goal": { "type": "gems", "counts": { - "red": 1, - "blue": 1, - "purple": 1, - "yellow": 1, - "green": 1 + "red": 5, + "blue": 5, + "purple": 5, + "yellow": 5, + "green": 5 } }, "hard": true, diff --git a/content/quests/packs/starter.json b/content/quests/packs/starter.json index a419fe8..5d0285e 100644 --- a/content/quests/packs/starter.json +++ b/content/quests/packs/starter.json @@ -56,13 +56,13 @@ "........", "........", "33.33333", - "3r33.3r3", + "3333.333", "000..000" ], "goal": { "type": "gems", "counts": { - "red": 2 + "red": 8 } }, "seed": 7, @@ -99,17 +99,17 @@ "board": [ "........", "........", - "..p...p.", + "..3...3.", "........", "3333.333", - "1p111...", + "13111...", "0000.000", "0000.000" ], "goal": { "type": "gems", "counts": { - "purple": 3 + "purple": 8 } }, "tray": [ @@ -144,19 +144,19 @@ "id": "s07", "board": [ "........", - ".r....p.", + ".3....3.", "........", "........", "3333.333", - "7r..7p77", + "73..7377", "77.77777", "000..000" ], "goal": { "type": "gems", "counts": { - "red": 2, - "purple": 2 + "red": 6, + "purple": 6 } }, "tray": [ @@ -191,19 +191,19 @@ { "id": "s09", "board": [ - "b.......", - ".4b.....", + "3.......", + ".43.....", "........", "........", "........", - "3b33.333", - "00b..000", + "3333.333", + "003..000", "111...11" ], "goal": { "type": "gems", "counts": { - "blue": 4 + "blue": 8 } }, "tray": [ @@ -238,19 +238,19 @@ "id": "s11", "board": [ "........", - ".g....g.", - "..r.....", + ".3....3.", + "..3.....", "........", "111...11", - "3g33.333", - "000r.000", + "3333.333", + "0003.000", "0000.000" ], "goal": { "type": "gems", "counts": { - "green": 3, - "red": 2 + "green": 6, + "red": 6 } }, "tray": [ @@ -285,11 +285,11 @@ { "id": "s13", "board": [ - "...p....", - "...p....", - "...p..0.", - "1..p..0.", - "1..p..0.", + "...3....", + "...3....", + "...3..0.", + "1..3..0.", + "1..3..0.", "1.....0.", "1..5..0.", "1..5..0." @@ -297,7 +297,7 @@ "goal": { "type": "gems", "counts": { - "purple": 5 + "purple": 8 } }, "seed": 99, @@ -333,21 +333,21 @@ { "id": "s15", "board": [ - "r..y..p.", + "3..3..3.", "........", ".6....6.", - "y..p..r.", + "3..3..3.", "111...11", - "3r3p3.33", + "33333.33", "4444..44", - "44y..444" + "443..444" ], "goal": { "type": "gems", "counts": { - "red": 3, - "purple": 3, - "yellow": 3 + "red": 5, + "purple": 5, + "yellow": 5 } }, "hard": true, diff --git a/content/quests/packs/treasure-trail.json b/content/quests/packs/treasure-trail.json index 946a781..0d8f507 100644 --- a/content/quests/packs/treasure-trail.json +++ b/content/quests/packs/treasure-trail.json @@ -29,19 +29,19 @@ "id": "t02", "board": [ "........", - ".r....b.", + ".3....3.", "........", "........", - "3r33.333", - "44..44b4", + "3333.333", + "44..4434", "44..4444", "000..000" ], "goal": { "type": "gems", "counts": { - "red": 2, - "blue": 2 + "red": 6, + "blue": 6 } }, "tray": [ @@ -75,9 +75,9 @@ { "id": "t04", "board": [ - "...p....", - "...p..3.", - ".0.p..3.", + "...3....", + "...3..3.", + ".0.3..3.", ".0....3.", ".0....3.", ".0....3.", @@ -87,7 +87,7 @@ "goal": { "type": "gems", "counts": { - "purple": 3 + "purple": 8 } }, "tray": [ @@ -122,19 +122,19 @@ "id": "t06", "board": [ "........", - "y......g", + "3......3", "........", "........", - "3y33.333", - "44..4g44", + "3333.333", + "44..4344", "44..4444", "111...11" ], "goal": { "type": "gems", "counts": { - "yellow": 2, - "green": 2 + "yellow": 6, + "green": 6 } }, "tray": [ @@ -169,19 +169,19 @@ { "id": "t08", "board": [ - "b..b....", + "3..3....", "........", "........", "000..000", "3333.333", - "4b44.444", + "4344.444", "4444..44", - "44b44.44" + "44344.44" ], "goal": { "type": "gems", "counts": { - "blue": 4 + "blue": 8 } }, "hard": true, @@ -217,20 +217,20 @@ { "id": "t10", "board": [ - "r..p...r", + "3..3...3", "........", "........", "000..000", "3333.333", - "5p5...55", - "555...r5", - "5p5...55" + "535...55", + "555...35", + "535...55" ], "goal": { "type": "gems", "counts": { - "red": 3, - "purple": 3 + "red": 6, + "purple": 6 } }, "hard": true, diff --git a/lib/game/block_painter.dart b/lib/game/block_painter.dart index 3adf05d..ea59643 100644 --- a/lib/game/block_painter.dart +++ b/lib/game/block_painter.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'dart:ui'; /// Draws one block cell in the shared beveled style: rounded fill with a @@ -37,3 +38,33 @@ void paintBlock(Canvas canvas, Rect rect, Color color, {double opacity = 1}) { Paint()..color = withA(Color.lerp(color, const Color(0xFF000000), .25)!), ); } + +/// Draws an 8-point gem star centered in [rect] with a highlight — shared +/// by the board and the tray so gems look identical everywhere. +void paintGemStar(Canvas canvas, Rect rect, Color color) { + final center = rect.center; + final outer = rect.shortestSide * 0.3; + final inner = outer * 0.45; + final path = Path(); + for (var i = 0; i < 16; i++) { + final radius = i.isEven ? outer : inner; + final angle = i * pi / 8 - pi / 2; + final p = Offset( + center.dx + radius * cos(angle), + center.dy + radius * sin(angle), + ); + i == 0 ? path.moveTo(p.dx, p.dy) : path.lineTo(p.dx, p.dy); + } + path.close(); + // Cheap solid offset shadow (no blur). + canvas.save(); + canvas.translate(outer * 0.12, outer * 0.14); + canvas.drawPath(path, Paint()..color = const Color(0x40000000)); + canvas.restore(); + canvas.drawPath(path, Paint()..color = color); + canvas.drawCircle( + center.translate(-outer * 0.25, -outer * 0.25), + outer * 0.16, + Paint()..color = const Color(0xAAFFFFFF), + ); +} diff --git a/lib/game/board_component.dart b/lib/game/board_component.dart index 0a74ad8..b5d1433 100644 --- a/lib/game/board_component.dart +++ b/lib/game/board_component.dart @@ -98,7 +98,15 @@ class BoardComponent extends PositionComponent Paint()..color = theme.emptyCell, ); } else if (occupant.gem != null) { - paintBlock(canvas, rect, puzzleBlockLight); + // 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), + ); _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 47c24ec..f1e6b99 100644 --- a/lib/game/tray_piece_component.dart +++ b/lib/game/tray_piece_component.dart @@ -5,6 +5,7 @@ import 'package:flame/effects.dart'; import 'package:flame/events.dart'; import 'package:flutter/animation.dart' show Curves; +import '../models/game_theme.dart'; import '../models/piece.dart'; import '../systems/game_constants.dart'; import 'block_painter.dart'; @@ -46,12 +47,17 @@ class TrayPieceComponent extends PositionComponent void render(Canvas canvas) { final cellSide = _trayCell; final color = game.theme.blockColor(piece.colorId); - for (final (r, c) in piece.cells) { - paintBlock( - canvas, - Rect.fromLTWH(c * cellSide, r * cellSide, cellSide, cellSide), - color, + final gems = game.state.gemsForSlot(trayIndex); + for (final (i, (r, c)) in piece.cells.indexed) { + final rect = Rect.fromLTWH( + c * cellSide, + r * cellSide, + cellSide, + cellSide, ); + paintBlock(canvas, rect, color); + final gem = gems[i]; + if (gem != null) paintGemStar(canvas, rect, gemColors[gem]!); } } diff --git a/lib/models/game_state.dart b/lib/models/game_state.dart index 725df01..1116984 100644 --- a/lib/models/game_state.dart +++ b/lib/models/game_state.dart @@ -14,6 +14,8 @@ class GameState { this.allClears = 0, this.clearFocus = false, this.gemsCollected = const {}, + this.trayGems = const [], + this.gemGoal = const {}, }); final Board board; @@ -21,6 +23,15 @@ class GameState { /// Piece ids of the current tray; `null` = slot already played. final List tray; + /// Gems carried by each tray slot (quest gem stages): index i maps a + /// piece-cell index to a gem color. Empty map = a plain piece. Parallel + /// to [tray]; empty overall in classic and non-gem stages. + final List> trayGems; + + /// The stage's gem target (quest gem stages), so the generator only + /// spawns colors still needed. Empty otherwise. + final Map gemGoal; + /// GameRng state to draw the next tray from. final int rngState; @@ -40,9 +51,15 @@ class GameState { /// Gems collected so far this run (quest mode). final Map gemsCollected; + /// Gems for tray slot [i], or an empty map if that slot has none. + Map gemsForSlot(int i) => + i < trayGems.length ? trayGems[i] : const {}; + GameState copyWith({ Board? board, List? tray, + List>? trayGems, + Map? gemGoal, int? rngState, int? score, int? combo, @@ -54,6 +71,8 @@ class GameState { return GameState( board: board ?? this.board, tray: tray ?? this.tray, + trayGems: trayGems ?? this.trayGems, + gemGoal: gemGoal ?? this.gemGoal, rngState: rngState ?? this.rngState, score: score ?? this.score, combo: combo ?? this.combo, @@ -75,11 +94,21 @@ class GameState { 'roundBestCombo': roundBestCombo, 'allClears': allClears, if (clearFocus) 'clearFocus': true, + if (trayGems.any((m) => m.isNotEmpty)) + 'trayGems': [ + for (final m in trayGems) + m.map((cell, color) => MapEntry('$cell', color.name)), + ], + if (gemGoal.isNotEmpty) + 'gemGoal': gemGoal.map((color, n) => MapEntry(color.name, n)), 'gems': gemsCollected.map((color, count) => MapEntry(color.name, count)), }; factory GameState.fromJson(Map json) { final gems = (json['gems'] as Map? ?? const {}).cast(); + final trayGemsJson = json['trayGems'] as List?; + final gemGoalJson = (json['gemGoal'] as Map? ?? const {}) + .cast(); return GameState( board: Board.fromJson(json['board'] as List), tray: (json['tray'] as List).cast(), @@ -90,6 +119,19 @@ class GameState { // Tolerant default: saves written before this field existed. allClears: json['allClears'] as int? ?? 0, clearFocus: json['clearFocus'] as bool? ?? false, + trayGems: trayGemsJson == null + ? const [] + : [ + for (final m in trayGemsJson) + { + for (final e in (m as Map).cast().entries) + int.parse(e.key): GemColor.values.byName(e.value), + }, + ], + gemGoal: { + for (final e in gemGoalJson.entries) + GemColor.values.byName(e.key): e.value, + }, gemsCollected: { for (final entry in gems.entries) GemColor.values.byName(entry.key): entry.value, diff --git a/lib/services/quest_validation.dart b/lib/services/quest_validation.dart index ac84754..61a10e3 100644 --- a/lib/services/quest_validation.dart +++ b/lib/services/quest_validation.dart @@ -7,7 +7,6 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; -import '../models/cell.dart'; import '../models/piece_catalog.dart'; import '../models/quest.dart'; import '../systems/line_clear.dart'; @@ -35,18 +34,14 @@ List validatePack(QuestPack pack) { problems.add('$where: only $empty empty cells — likely unplayable'); } + // Gems now ride on generated tray pieces (spawned only for colors + // still needed), so a gem goal is satisfiable regardless of what's on + // the board — only sanity-check the counts. if (stage.goal case GemsGoal(counts: final counts)) { - final onBoard = {}; - for (final cell in stage.board.cells) { - final gem = cell?.gem; - if (gem != null) onBoard[gem] = (onBoard[gem] ?? 0) + 1; - } counts.forEach((color, needed) { - final available = onBoard[color] ?? 0; - if (available < needed) { + if (needed < 1 || needed > 60) { problems.add( - '$where: goal needs $needed ${color.name} gems but the board ' - 'only has $available', + '$where: gem goal for ${color.name} is $needed (want 1..60)', ); } }); diff --git a/lib/state/quest_game_controller.dart b/lib/state/quest_game_controller.dart index 7f695db..f9d37d8 100644 --- a/lib/state/quest_game_controller.dart +++ b/lib/state/quest_game_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/cell.dart'; import '../models/game_state.dart'; import '../models/quest.dart'; import '../systems/game_engine.dart'; @@ -65,6 +66,12 @@ class QuestGameController extends Notifier { void start(QuestPack pack, QuestStage stage, {required int levelNumber}) { final seed = stage.seed ?? DateTime.now().microsecondsSinceEpoch; + // Gem stages: gems ride on the generated tray pieces, spawned only for + // colors still needed — so the goal can exceed what's on the board. + final gemGoal = switch (stage.goal) { + GemsGoal(counts: final c) => c, + ScoreGoal() => const {}, + }; state = QuestRun( pack: pack, stage: stage, @@ -73,6 +80,7 @@ class QuestGameController extends Notifier { seed, board: stage.board, initialTray: stage.tray, + gemGoal: gemGoal, ), status: QuestStatus.playing, ); diff --git a/lib/systems/game_constants.dart b/lib/systems/game_constants.dart index bd7917a..50293a2 100644 --- a/lib/systems/game_constants.dart +++ b/lib/systems/game_constants.dart @@ -58,5 +58,13 @@ const double classicOpenBoardFullness = 0.2; /// can clear the most lines when played well. const int classicDrawAttempts = 14; +// --- Quest gem spawning (gems ride on generated tray pieces) ---------- + +/// Chance a generated tray piece carries a gem (quest gem stages). +const double gemSpawnChance = 0.5; + +/// Chance a gem-carrying piece of ≥4 cells carries a second gem. +const double gemSecondChance = 0.25; + /// Search budget for canPlaceAllInSomeOrder; past this, assume playable. const int solvabilityNodeCap = 1500; diff --git a/lib/systems/game_engine.dart b/lib/systems/game_engine.dart index f33fc55..9d8db6c 100644 --- a/lib/systems/game_engine.dart +++ b/lib/systems/game_engine.dart @@ -60,22 +60,33 @@ class GameEngine { Board? board, List? initialTray, bool clearFocus = false, + Map gemGoal = const {}, }) { assert(initialTray == null || initialTray.length == traySize); assert(initialTray?.every(pieceById.containsKey) ?? true); final startBoard = board ?? Board.empty(); final rng = GameRng(seed); - final tray = - initialTray ?? - [ - for (final p in PieceGenerator( - rng, - ).nextTray(startBoard, clearFocus: clearFocus)) - p.id, - ]; + final gen = PieceGenerator(rng); + + final List tray; + final List> trayGems; + if (initialTray != null) { + // The designed opening break is gem-free; gems start on the next tray. + tray = initialTray; + trayGems = [for (final _ in tray) const {}]; + } else { + final pieces = gen.nextTray(startBoard, clearFocus: clearFocus); + tray = [for (final p in pieces) p.id]; + trayGems = gemGoal.isEmpty + ? [for (final _ in pieces) const {}] + : gen.assignGems(pieces, gemGoal); + } + return GameState( board: startBoard, tray: List.of(tray), + trayGems: trayGems, + gemGoal: gemGoal, rngState: rng.state, score: 0, combo: 0, @@ -97,7 +108,13 @@ class GameEngine { final piece = pieceById[pieceId]!; if (!placement.canPlace(state.board, piece, row, col)) return null; - final stamped = placement.stamp(state.board, piece, row, col); + final stamped = placement.stampWithGems( + state.board, + piece, + row, + col, + state.gemsForSlot(trayIndex), + ); final clear = clearFullLines(stamped); // Combo: any clearing placement extends the streak; a placement that @@ -110,27 +127,45 @@ class GameEngine { } if (allClear) delta += allClearBonus; + // Merge any collected gems first — the refill spawns only colors still + // needed after this placement. + final gems = state.gemsCollected; + final updatedCollected = clear.gems.isEmpty + ? gems + : { + for (final color in GemColor.values) + if ((gems[color] ?? 0) + (clear.gems[color] ?? 0) > 0) + color: (gems[color] ?? 0) + (clear.gems[color] ?? 0), + }; + var tray = [ for (var i = 0; i < state.tray.length; i++) i == trayIndex ? null : state.tray[i], ]; + var trayGems = [ + for (var i = 0; i < state.tray.length; i++) + if (i == trayIndex) const {} else state.gemsForSlot(i), + ]; var rngState = state.rngState; final trayRefilled = tray.every((id) => id == null); if (trayRefilled) { final rng = GameRng.fromState(rngState); - tray = [ - for (final p in PieceGenerator( - rng, - ).nextTray(clear.board, clearFocus: state.clearFocus)) - p.id, - ]; + final gen = PieceGenerator(rng); + final pieces = gen.nextTray(clear.board, clearFocus: state.clearFocus); + tray = [for (final p in pieces) p.id]; + trayGems = state.gemGoal.isEmpty + ? [for (final _ in pieces) const {}] + : gen.assignGems(pieces, { + for (final e in state.gemGoal.entries) + e.key: e.value - (updatedCollected[e.key] ?? 0), + }); rngState = rng.state; } - final gems = state.gemsCollected; final nextState = state.copyWith( board: clear.board, tray: tray, + trayGems: trayGems, rngState: rngState, score: state.score + delta, combo: combo, @@ -138,13 +173,7 @@ class GameEngine { ? combo : state.roundBestCombo, allClears: allClear ? state.allClears + 1 : state.allClears, - gemsCollected: clear.gems.isEmpty - ? gems - : { - for (final color in GemColor.values) - if ((gems[color] ?? 0) + (clear.gems[color] ?? 0) > 0) - color: (gems[color] ?? 0) + (clear.gems[color] ?? 0), - }, + gemsCollected: updatedCollected, ); return PlacementOutcome( nextState, diff --git a/lib/systems/piece_generator.dart b/lib/systems/piece_generator.dart index cbefdc9..9b24083 100644 --- a/lib/systems/piece_generator.dart +++ b/lib/systems/piece_generator.dart @@ -1,4 +1,5 @@ import '../models/board.dart'; +import '../models/cell.dart'; import '../models/piece.dart'; import '../models/piece_catalog.dart'; import 'game_constants.dart'; @@ -112,6 +113,45 @@ class PieceGenerator { return solvableFallback ?? best; } + /// Assigns gems to a freshly-drawn [tray] for a quest gem stage. Only + /// colors still [needed] (need > 0) are spawned, weighted by how many are + /// left. Each piece has a chance of carrying one gem (bigger pieces may + /// carry two); many pieces carry none. Consumes [rng] deterministically, + /// so it must run right after [nextTray] using the same generator. + List> assignGems( + List tray, + Map needed, + ) { + final colors = [ + for (final e in needed.entries) + if (e.value > 0) e, + ]; + if (colors.isEmpty) { + return List.generate(tray.length, (_) => const {}); + } + var total = colors.fold(0, (a, e) => a + e.value); + GemColor pick() { + var roll = rng.nextInt(total); + for (final e in colors) { + roll -= e.value; + if (roll < 0) return e.key; + } + return colors.last.key; + } + + return [ + for (final piece in tray) + if (rng.nextDouble() < gemSpawnChance) + { + rng.nextInt(piece.cells.length): pick(), + if (piece.cells.length >= 4 && rng.nextDouble() < gemSecondChance) + rng.nextInt(piece.cells.length): pick(), + } + else + const {}, + ]; + } + Piece _weightedDraw(List pieces, double Function(Piece) weightOf) { var total = 0.0; for (final piece in pieces) { diff --git a/lib/systems/placement.dart b/lib/systems/placement.dart index 420b229..204f50d 100644 --- a/lib/systems/placement.dart +++ b/lib/systems/placement.dart @@ -27,6 +27,27 @@ Board stamp(Board board, Piece piece, int row, int col) { }); } +/// Like [stamp], but attaches gems to the placed cells: [gems] maps a +/// piece-cell index (into `piece.cells`) to a gem color. Used for the +/// quest gem stages where tray pieces carry gems. +Board stampWithGems( + Board board, + Piece piece, + int row, + int col, + Map gems, +) { + assert(canPlace(board, piece, row, col)); + final updates = {}; + for (final (i, (r, c)) in piece.cells.indexed) { + updates[(row + r) * Board.size + (col + c)] = Cell( + piece.colorId, + gem: gems[i], + ); + } + return board.withUpdates(updates); +} + /// True if [piece] fits somewhere on [board]. bool fitsAnywhere(Board board, Piece piece) { for (var row = 0; row <= Board.size - piece.height; row++) { diff --git a/test/game_engine_test.dart b/test/game_engine_test.dart index 53a8908..ef16d71 100644 --- a/test/game_engine_test.dart +++ b/test/game_engine_test.dart @@ -214,4 +214,84 @@ void main() { final withSingle = bigOnly.copyWith(tray: ['square3', 'single', null]); expect(GameEngine.isGameOver(withSingle), isFalse); }); + + group('tray gems', () { + test('placing a gem-carrying piece into a clearing line collects it', () { + final state = GameEngine.newGame(1, gemGoal: {GemColor.red: 5}).copyWith( + board: boardFrom([ + '........', + '........', + '........', + '........', + '........', + '........', + '........', + '1111111.', // row 7 needs (7,7) + ]), + tray: ['single', 'single', 'single'], + trayGems: [ + {0: GemColor.red}, // this single carries a red gem + const {}, + const {}, + ], + gemGoal: {GemColor.red: 5}, + ); + final outcome = GameEngine.place(state, 0, 7, 7)!; + expect(outcome.events.clearedRows, [7]); + expect(outcome.events.gems, {GemColor.red: 1}); + expect(outcome.state.gemsCollected, {GemColor.red: 1}); + }); + + test('refill spawns only colors still needed', () { + // red already met (goal 3, collected 3); only blue remains. + var state = GameEngine.newGame( + 7, + gemGoal: {GemColor.red: 3, GemColor.blue: 3}, + ); + state = state.copyWith( + gemsCollected: {GemColor.red: 3}, + // Force a one-piece tray so the next place refills. + tray: ['single', null, null], + trayGems: [const {}, const {}, const {}], + board: boardFrom([ + '........', + '........', + '........', + '........', + '........', + '........', + '........', + '1111111.', + ]), + ); + final refilled = GameEngine.place(state, 0, 7, 7)!.state; + final spawned = refilled.trayGems.expand((m) => m.values).toSet(); + expect(spawned, isNot(contains(GemColor.red))); + }); + + test('collected gems may exceed the goal', () { + final state = GameEngine.newGame(1, gemGoal: {GemColor.red: 1}).copyWith( + board: boardFrom([ + '........', + '........', + '........', + '........', + '........', + '........', + '........', + '11111...', + ]), + // A 3-wide line piece carrying 3 red gems completes the row. + tray: ['line3h', null, null], + trayGems: [ + {0: GemColor.red, 1: GemColor.red, 2: GemColor.red}, + const {}, + const {}, + ], + gemGoal: {GemColor.red: 1}, + ); + final outcome = GameEngine.place(state, 0, 7, 5)!; + expect(outcome.state.gemsCollected[GemColor.red], greaterThan(1)); + }); + }); } diff --git a/test/piece_generator_test.dart b/test/piece_generator_test.dart index 8292fb7..837ec76 100644 --- a/test/piece_generator_test.dart +++ b/test/piece_generator_test.dart @@ -1,4 +1,6 @@ import 'package:block_puzzle/models/board.dart'; +import 'package:block_puzzle/models/cell.dart'; +import 'package:block_puzzle/models/piece_catalog.dart'; import 'package:block_puzzle/systems/game_constants.dart'; import 'package:block_puzzle/systems/piece_generator.dart'; import 'package:block_puzzle/systems/placement.dart'; @@ -153,6 +155,35 @@ void main() { expect(a.map((p) => p.id).toList(), b.map((p) => p.id).toList()); }); + test('assignGems spawns only needed colors, deterministically', () { + final tray = [ + pieceById['line3h']!, + pieceById['square2']!, + pieceById['single']!, + ]; + final a = PieceGenerator(GameRng(3)).assignGems(tray, { + GemColor.red: 5, + GemColor.blue: 0, // met -> must not spawn + }); + final b = PieceGenerator( + GameRng(3), + ).assignGems(tray, {GemColor.red: 5, GemColor.blue: 0}); + expect(a, b, reason: 'deterministic per seed'); + final colors = a.expand((m) => m.values).toSet(); + expect(colors, isNot(contains(GemColor.blue))); + expect(colors, contains(GemColor.red)); + }); + + test('assignGems with nothing needed spawns no gems', () { + final tray = [ + pieceById['line5h']!, + pieceById['single']!, + pieceById['tUp']!, + ]; + final result = PieceGenerator(GameRng(1)).assignGems(tray, const {}); + expect(result.every((m) => m.isEmpty), isTrue); + }); + test('fresh trays are always placeable while any piece fits', () { // Only 'single' (rare, weight 0.25) fits this board, so unguarded // draws would routinely produce dead trays. diff --git a/test/quest_parser_test.dart b/test/quest_parser_test.dart index f506272..f1a4344 100644 --- a/test/quest_parser_test.dart +++ b/test/quest_parser_test.dart @@ -134,7 +134,7 @@ void main() { }); group('playability validation', () { - test('flags pre-completed lines and impossible gem goals', () { + test('flags pre-completed lines', () { final pack = QuestPack.fromJson({ 'schema': 1, 'id': 'p', @@ -151,17 +151,17 @@ void main() { '........', '........', ], + // Gem goal is satisfiable regardless of the board now (gems + // come from the tray), so no gem-impossibility problem. goal: { 'type': 'gems', - 'counts': {'blue': 1}, // no blue gems on board + 'counts': {'blue': 5}, }, ), ], }); final problems = validatePack(pack); - expect(problems, hasLength(2)); - expect(problems.first, contains('pre-completed')); - expect(problems.last, contains('blue')); + expect(problems.single, contains('pre-completed')); }); }); diff --git a/test/save_data_test.dart b/test/save_data_test.dart index 2897fa0..1c7aca3 100644 --- a/test/save_data_test.dart +++ b/test/save_data_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:block_puzzle/models/cell.dart'; import 'package:block_puzzle/models/game_state.dart'; import 'package:block_puzzle/models/save_data.dart'; import 'package:block_puzzle/systems/game_engine.dart'; @@ -78,6 +79,27 @@ void main() { expect(restored.rngState, -8423581294942912); }); + test('tray gems and gem goal round-trip through JSON', () { + final state = GameEngine.newGame(1).copyWith( + tray: ['single', 'line2h', null], + trayGems: [ + {0: GemColor.red}, + {1: GemColor.blue}, + const {}, + ], + gemGoal: {GemColor.red: 5, GemColor.blue: 3}, + ); + final restored = GameState.fromJson( + (jsonDecode(jsonEncode(state.toJson())) as Map).cast(), + ); + expect(restored.trayGems, [ + {0: GemColor.red}, + {1: GemColor.blue}, + const {}, + ]); + expect(restored.gemGoal, {GemColor.red: 5, GemColor.blue: 3}); + }); + test('unknown save version throws instead of silently loading', () { expect( () => SaveData.fromJson(const {'version': 999}),