From b6320f2113b9fbee95efbb8602624bcb101d9bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hyunmin=20Yoo=20=28=EC=9C=A0=ED=98=84=EB=AF=BC=29?= <67991188+uhmin99@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:53:09 +0900 Subject: [PATCH] feat: forgiving ghost preview, neutral quest tiles, easy classic clears (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playtest round 4 (4 items). 1. Ghost preview: over the board it always shows a pre-place now, snapping to the nearest legal cell so you don't need to hit the exact spot; the drop lands where the ghost shows. Return-to-tray only when released off the board. Over-board-with-no-fit shows a red invalid ghost. 2. Quest colors: pre-placed puzzle blocks and gem cells render in one neutral light tile (new Cell.preplaced flag + puzzleBlockLight) so the puzzle reads as a unit and the player's vivid pieces pop, matching the reference. Player-placed blocks keep palette colors. 3. Classic clears/combos much easier: GameState.clearFocus threads a mode flag to the generator — breakers boosted ~9x, near-empty boards favor big/long pieces (multi-line clears + all-clears), and the deal keeps the set with the best clearingPotential (greedy line-clear estimate). Quest keeps its balance. Deterministic per seed. New allocation-free countCompletedLines; tests for clearingPotential and the clearFocus biases. Also: iOS project + plugins verified iOS-ready (can't build on this Mac — missing iOS platform SDK + signing cert, both user-side). Co-authored-by: Claude Fable 5 --- ARCHITECTURE.md | 7 +++ PROGRESS.md | 18 ++++++ SAVE_MODEL.md | 4 +- lib/game/block_puzzle_game.dart | 80 +++++++++++++++++++------- lib/game/board_component.dart | 27 ++++++++- lib/game/tray_piece_component.dart | 2 +- lib/models/board_strings.dart | 7 ++- lib/models/cell.dart | 17 +++++- lib/models/game_state.dart | 8 +++ lib/models/game_theme.dart | 7 ++- lib/state/classic_game_controller.dart | 3 +- lib/systems/game_constants.dart | 20 +++++++ lib/systems/game_engine.dart | 16 +++++- lib/systems/piece_generator.dart | 45 +++++++++++++-- lib/systems/placement.dart | 19 ++++-- lib/systems/solvability.dart | 36 ++++++++++++ test/piece_generator_test.dart | 55 ++++++++++++++++++ test/placement_test.dart | 15 +++++ 18 files changed, 343 insertions(+), 43 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 23dbb18..4a6f5be 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -92,6 +92,13 @@ 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. +**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 +set up multi-line clears and all-clears), and among more candidate sets +it keeps the one with the highest `clearingPotential` (a greedy estimate +of lines the set can clear). Quest keeps the balanced defaults. + Classic rolls a random seed per run (persisted); quest stages may pin a seed in their data. diff --git a/PROGRESS.md b/PROGRESS.md index 58c332f..d267417 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-15 — Playtest round 4: preview, quest colors, classic clears +- Ghost preview: over the board it now ALWAYS shows a pre-place, snapping + to the nearest legal cell (forgiving placement); the drop lands there. + Return-to-tray only happens when released off the board. Over-board-but- + no-fit shows a red invalid ghost. +- Quest visual: pre-placed puzzle blocks AND gem cells now render in one + neutral light tile (Cell.preplaced flag; puzzleBlockLight), so the + puzzle reads as a unit and the player's vivid pieces stand out (per the + reference image). Player-placed cells keep palette colors. +- Classic clears much easier: GameState.clearFocus threads a mode flag to + the generator — breakers boosted ~9×, open boards favor big/long pieces + for multi-line clears + all-clears, and the deal keeps the set with the + best clearingPotential. Quest keeps its balance. Deterministic per seed, + tests added (countCompletedLines, clearingPotential, clearFocus bias). +- iOS: verified the project + all plugins are iOS-ready; can't build here + (this Mac lacks the iOS platform SDK in Xcode and a signing cert — both + user-side). Steps for the user in RELEASING.md / final summary. + ### 2026-07-15 — Perf: thermal throttling after 5–10 min of play - User report: sound/vibration/animation all degrade and the phone gets hot after 5–10 min. Diagnosed with a monitored on-device soak: a diff --git a/SAVE_MODEL.md b/SAVE_MODEL.md index ac4c1a3..64bd8c2 100644 --- a/SAVE_MODEL.md +++ b/SAVE_MODEL.md @@ -45,13 +45,15 @@ silently discarding progress. "combo": 3, "roundBestCombo": 7, "allClears": 1, // this run; absent in old saves -> 0 + "clearFocus": true, // classic mode's easy-clears dealing "gems": {"red": 1} // collected this run (quest only) } ``` Notes: - Board cell: `null` = empty; otherwise `c` = theme palette color index, - optional `g` = gem color name (quest boards only). + optional `g` = gem color name (quest boards only), optional `p` = true + for pre-placed quest puzzle blocks (rendered in the neutral light tile). - 64-bit integers (`rngState`, `classicRunSeed`) are stored as **strings** because JSON tooling commonly round-trips numbers through doubles, which corrupts values above 2^53. diff --git a/lib/game/block_puzzle_game.dart b/lib/game/block_puzzle_game.dart index 37c3c92..43e72f2 100644 --- a/lib/game/block_puzzle_game.dart +++ b/lib/game/block_puzzle_game.dart @@ -4,6 +4,7 @@ import 'package:flame/components.dart'; import 'package:flame/game.dart'; import 'package:flutter/foundation.dart'; +import '../models/board.dart'; import '../models/game_state.dart'; import '../models/game_theme.dart'; import '../models/piece.dart'; @@ -174,37 +175,76 @@ class BlockPuzzleGame extends FlameGame { _renderedTray = List.of(state.tray); } - /// Updates [preview] for a piece hovering with its top-left cell corner - /// at [pieceTopLeft] (canvas coords). Null when off-board/illegal cells - /// still produce a preview with legal=false so the ghost can hide. + /// Updates [preview] while a piece hovers with its top-left cell corner + /// at [pieceTopLeft]. Behaviour (per playtest feedback): + /// - off the board → no preview (the drop will bounce back to the tray), + /// - over the board → always show a ghost. Snap to the nearest legal + /// spot so there is always a pre-place, and the drop lands there — + /// you don't have to hit the exact cell. + /// - over the board but the piece fits nowhere → an invalid (red) ghost + /// at the clamped position; the drop bounces. void updatePreview(Piece piece, Offset pieceTopLeft) { - final (row, col) = geometry.snapCell(pieceTopLeft); - final legal = canPlace(state.board, piece, row, col); - var rows = const []; - var cols = const []; - if (legal) { - final result = clearFullLines(stamp(state.board, piece, row, col)); - rows = result.rows; - cols = result.cols; + final cell = geometry.cell; + final center = + pieceTopLeft + Offset(piece.width * cell / 2, piece.height * cell / 2); + if (!geometry.boardRect.inflate(cell * 0.6).contains(center)) { + preview = null; // off the grid + return; } + final (rawRow, rawCol) = geometry.snapCell(pieceTopLeft); + final resolved = _nearestLegal(piece, rawRow, rawCol); + if (resolved == null) { + preview = DragPreview( + piece: piece, + row: rawRow.clamp(0, Board.size - piece.height), + col: rawCol.clamp(0, Board.size - piece.width), + legal: false, + wouldClearRows: const [], + wouldClearCols: const [], + ); + return; + } + final (row, col) = resolved; + final result = clearFullLines(stamp(state.board, piece, row, col)); preview = DragPreview( piece: piece, row: row, col: col, - legal: legal, - wouldClearRows: rows, - wouldClearCols: cols, + legal: true, + wouldClearRows: result.rows, + wouldClearCols: result.cols, ); } + /// Closest legal placement to the raw snap (Euclidean in cell space), + /// or null if the piece fits nowhere. + (int, int)? _nearestLegal(Piece piece, int rawRow, int rawCol) { + final maxRow = Board.size - piece.height; + final maxCol = Board.size - piece.width; + if (maxRow < 0 || maxCol < 0) return null; + (int, int)? best; + var bestDist = 1 << 30; + for (var r = 0; r <= maxRow; r++) { + for (var c = 0; c <= maxCol; c++) { + if (!canPlace(state.board, piece, r, c)) continue; + final d = (r - rawRow) * (r - rawRow) + (c - rawCol) * (c - rawCol); + if (d < bestDist) { + bestDist = d; + best = (r, c); + } + } + } + return best; + } + void clearPreview() => preview = null; - /// Attempts the drop; returns the outcome (null = bounce back to tray). - PlacementOutcome? tryPlace(int trayIndex, Offset pieceTopLeft) { - final piece = pieceById[state.tray[trayIndex]!]!; - final (row, col) = geometry.snapCell(pieceTopLeft); - if (!canPlace(state.board, piece, row, col)) return null; - final outcome = onPlace(trayIndex, row, col); + /// Drops [trayIndex] at the resolved preview cell. Returns null (bounce + /// to tray) when there is no legal preview — i.e. off-grid or no fit. + PlacementOutcome? tryPlace(int trayIndex) { + final p = preview; + if (p == null || !p.legal) return null; + final outcome = onPlace(trayIndex, p.row, p.col); if (outcome != null) spawnPlacementEffects(this, outcome.events); return outcome; } diff --git a/lib/game/board_component.dart b/lib/game/board_component.dart index 184fbdd..f9bac4b 100644 --- a/lib/game/board_component.dart +++ b/lib/game/board_component.dart @@ -98,8 +98,11 @@ class BoardComponent extends PositionComponent Paint()..color = theme.emptyCell, ); } else if (occupant.gem != null) { - paintBlock(canvas, rect, gemBlockGold); + paintBlock(canvas, rect, puzzleBlockLight); _paintGem(canvas, rect, gemColors[occupant.gem]!); + } else if (occupant.preplaced) { + // Pre-placed puzzle blocks share the neutral light tile. + paintBlock(canvas, rect, puzzleBlockLight); } else { paintBlock(canvas, rect, theme.blockColor(occupant.colorId)); } @@ -107,7 +110,27 @@ class BoardComponent extends PositionComponent } final preview = game.preview; - if (preview == null || !preview.legal) return; + if (preview == null) return; + + // Over the board but the piece fits nowhere near: a red invalid ghost + // so there is still a pre-place (the drop will bounce). + if (!preview.legal) { + for (final (r, c) in preview.piece.cells) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + (preview.col + c) * cell, + (preview.row + r) * cell, + cell, + cell, + ).deflate(cell * 0.06), + Radius.circular(cell * 0.12), + ), + Paint()..color = const Color(0x55E53935), + ); + } + return; + } // Would-clear lines glow across their full length. final glow = Paint()..color = theme.lineHighlight; diff --git a/lib/game/tray_piece_component.dart b/lib/game/tray_piece_component.dart index 1311665..47c24ec 100644 --- a/lib/game/tray_piece_component.dart +++ b/lib/game/tray_piece_component.dart @@ -110,7 +110,7 @@ class TrayPieceComponent extends PositionComponent if (!_dragging) return; _dragging = false; game.dragActive = false; - final dropped = game.tryPlace(trayIndex, _topLeftCorner()); + final dropped = game.tryPlace(trayIndex); game.clearPreview(); if (dropped == null) _returnToTray(); // On success the state listener rebuilds the tray, removing this diff --git a/lib/models/board_strings.dart b/lib/models/board_strings.dart index 4345079..a7b5814 100644 --- a/lib/models/board_strings.dart +++ b/lib/models/board_strings.dart @@ -34,16 +34,19 @@ Board parseBoardRows(List rows) { ); } for (final ch in row.split('')) { + // Cells authored in a board string are the pre-placed puzzle + // (quest stages) — mark them so they render in the neutral light + // color rather than a vivid player-block color. if (ch == '.') { cells.add(null); } else if (gemLetters.containsKey(ch)) { - cells.add(Cell(gemBlockColorId, gem: gemLetters[ch])); + cells.add(Cell(gemBlockColorId, gem: gemLetters[ch], preplaced: true)); } else { final colorId = int.tryParse(ch); if (colorId == null || colorId > 7) { throw FormatException('Unknown board char "$ch" in row $r'); } - cells.add(Cell(colorId)); + cells.add(Cell(colorId, preplaced: true)); } } } diff --git a/lib/models/cell.dart b/lib/models/cell.dart index 2344068..847eeff 100644 --- a/lib/models/cell.dart +++ b/lib/models/cell.dart @@ -5,16 +5,23 @@ enum GemColor { red, blue, purple, yellow, green } /// One occupied board cell. Empty cells are represented as `null` in /// [Board.cells], so this class only models occupancy. class Cell { - const Cell(this.colorId, {this.gem}); + const Cell(this.colorId, {this.gem, this.preplaced = false}); /// Index into the active theme's block palette. final int colorId; final GemColor? gem; + /// True for a quest stage's pre-placed puzzle blocks (and gem cells), + /// as opposed to blocks the player dropped. Pre-placed cells render in + /// one neutral light color so gems and the puzzle read as a unit and + /// the player's own vivid pieces stand out against it. + final bool preplaced; + Map toJson() => { 'c': colorId, if (gem != null) 'g': gem!.name, + if (preplaced) 'p': true, }; factory Cell.fromJson(Map json) { @@ -22,13 +29,17 @@ class Cell { return Cell( json['c'] as int, gem: gemName == null ? null : GemColor.values.byName(gemName), + preplaced: json['p'] as bool? ?? false, ); } @override bool operator ==(Object other) => - other is Cell && other.colorId == colorId && other.gem == gem; + other is Cell && + other.colorId == colorId && + other.gem == gem && + other.preplaced == preplaced; @override - int get hashCode => Object.hash(colorId, gem); + int get hashCode => Object.hash(colorId, gem, preplaced); } diff --git a/lib/models/game_state.dart b/lib/models/game_state.dart index 1751b37..725df01 100644 --- a/lib/models/game_state.dart +++ b/lib/models/game_state.dart @@ -12,6 +12,7 @@ class GameState { required this.combo, required this.roundBestCombo, this.allClears = 0, + this.clearFocus = false, this.gemsCollected = const {}, }); @@ -33,6 +34,9 @@ class GameState { /// All-clears earned this run (drives the end-of-run summary variants). final int allClears; + /// Classic mode: bias tray generation hard toward clears/combos. + final bool clearFocus; + /// Gems collected so far this run (quest mode). final Map gemsCollected; @@ -44,6 +48,7 @@ class GameState { int? combo, int? roundBestCombo, int? allClears, + bool? clearFocus, Map? gemsCollected, }) { return GameState( @@ -54,6 +59,7 @@ class GameState { combo: combo ?? this.combo, roundBestCombo: roundBestCombo ?? this.roundBestCombo, allClears: allClears ?? this.allClears, + clearFocus: clearFocus ?? this.clearFocus, gemsCollected: gemsCollected ?? this.gemsCollected, ); } @@ -68,6 +74,7 @@ class GameState { 'combo': combo, 'roundBestCombo': roundBestCombo, 'allClears': allClears, + if (clearFocus) 'clearFocus': true, 'gems': gemsCollected.map((color, count) => MapEntry(color.name, count)), }; @@ -82,6 +89,7 @@ class GameState { roundBestCombo: json['roundBestCombo'] as int, // Tolerant default: saves written before this field existed. allClears: json['allClears'] as int? ?? 0, + clearFocus: json['clearFocus'] as bool? ?? false, gemsCollected: { for (final entry in gems.entries) GemColor.values.byName(entry.key): entry.value, diff --git a/lib/models/game_theme.dart b/lib/models/game_theme.dart index 55e50c7..3178b86 100644 --- a/lib/models/game_theme.dart +++ b/lib/models/game_theme.dart @@ -11,8 +11,11 @@ const Map gemColors = { GemColor.green: Color(0xFF66DD66), }; -/// Blocks that carry a gem render in gold regardless of theme palette. -const Color gemBlockGold = Color(0xFFE8B454); +/// Neutral light tile for a quest stage's pre-placed puzzle — both plain +/// pre-placed blocks and gem-carrying blocks use it, so the puzzle reads +/// as one light substrate with gems on top and the player's own vivid +/// pieces stand out against it. +const Color puzzleBlockLight = Color(0xFFE8D6A6); /// Visual skin for the board and blocks. All rendering colors come from /// here so new themes are pure data (a contribution target — never locked diff --git a/lib/state/classic_game_controller.dart b/lib/state/classic_game_controller.dart index 2c3833d..1592d2c 100644 --- a/lib/state/classic_game_controller.dart +++ b/lib/state/classic_game_controller.dart @@ -53,7 +53,8 @@ class ClassicGameController extends Notifier { ? _pinnedSeed : DateTime.now().microsecondsSinceEpoch); _seed = s; - final fresh = GameEngine.newGame(s); + // Classic leans into clears/combos (quest keeps its designed balance). + final fresh = GameEngine.newGame(s, clearFocus: true); state = fresh; ref.read(saveDataProvider.notifier).storeClassicRun(fresh, s); } diff --git a/lib/systems/game_constants.dart b/lib/systems/game_constants.dart index 2518cde..bd7917a 100644 --- a/lib/systems/game_constants.dart +++ b/lib/systems/game_constants.dart @@ -38,5 +38,25 @@ const double snugBoost = 3.0; /// playable in sequence (and ideally contains a breaker) before settling. const int traySetDrawAttempts = 6; +// --- Classic "clear focus" dealing ----------------------------------- +// Classic mode leans hard into clears and combos: the whole point is the +// satisfaction of breaking lines, so deals should almost always enable a +// clear, and open boards should hand out big/long pieces to set up +// multi-line clears and all-clears. + +/// Breaker weight multiplier in clear-focus mode (vs [breakerBoost]). +const double classicBreakerBoost = 9.0; + +/// On a near-empty board, favor large pieces this strongly (weight × +/// `1 + cellCount * classicBigPieceBias`) so rows fill fast. +const double classicBigPieceBias = 0.6; + +/// Board fill fraction below which the big-piece bias applies. +const double classicOpenBoardFullness = 0.2; + +/// Clear-focus draws more candidate sets and keeps the one whose pieces +/// can clear the most lines when played well. +const int classicDrawAttempts = 14; + /// 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 fc64c58..5915309 100644 --- a/lib/systems/game_engine.dart +++ b/lib/systems/game_engine.dart @@ -54,6 +54,7 @@ class GameEngine { int seed, { Board? board, List? initialTray, + bool clearFocus = false, }) { assert(initialTray == null || initialTray.length == traySize); assert(initialTray?.every(pieceById.containsKey) ?? true); @@ -61,7 +62,12 @@ class GameEngine { final rng = GameRng(seed); final tray = initialTray ?? - [for (final p in PieceGenerator(rng).nextTray(startBoard)) p.id]; + [ + for (final p in PieceGenerator( + rng, + ).nextTray(startBoard, clearFocus: clearFocus)) + p.id, + ]; return GameState( board: startBoard, tray: List.of(tray), @@ -69,6 +75,7 @@ class GameEngine { score: 0, combo: 0, roundBestCombo: 0, + clearFocus: clearFocus, ); } @@ -106,7 +113,12 @@ class GameEngine { final trayRefilled = tray.every((id) => id == null); if (trayRefilled) { final rng = GameRng.fromState(rngState); - tray = [for (final p in PieceGenerator(rng).nextTray(clear.board)) p.id]; + tray = [ + for (final p in PieceGenerator( + rng, + ).nextTray(clear.board, clearFocus: state.clearFocus)) + p.id, + ]; rngState = rng.state; } diff --git a/lib/systems/piece_generator.dart b/lib/systems/piece_generator.dart index 00ce592..cbefdc9 100644 --- a/lib/systems/piece_generator.dart +++ b/lib/systems/piece_generator.dart @@ -21,12 +21,18 @@ import 'solvability.dart'; /// sets that contain a breaker when a break is possible at all). The /// player might not find the order, but one always exists while the /// board allows it. +/// +/// [clearFocus] (classic mode) turns this up: breakers are boosted far +/// harder, open boards favor big/long pieces to set up multi-line clears +/// and all-clears, and among more candidate sets the one that can clear +/// the most lines (by [clearingPotential]) is chosen — so clears and +/// combos happen almost every tray. class PieceGenerator { PieceGenerator(this.rng); final GameRng rng; - List nextTray(Board board) { + List nextTray(Board board, {bool clearFocus = false}) { final profiles = { for (final piece in pieceCatalog) piece.id: FitProfile.of(board, piece), }; @@ -38,12 +44,23 @@ class PieceGenerator { for (final piece in pieceCatalog) if (profiles[piece.id]!.canBreak) piece.id, }; + + final filled = board.cells.where((c) => c != null).length; + final openBoard = + clearFocus && + filled / (Board.size * Board.size) < classicOpenBoardFullness; + final breakMult = clearFocus ? classicBreakerBoost : breakerBoost; + double effectiveWeight(Piece p) { final prof = profiles[p.id]!; - return p.weight * + var w = + p.weight * (prof.fits ? 1.0 : fitPenalty) * - (prof.canBreak ? breakerBoost : 1.0) * + (prof.canBreak ? breakMult : 1.0) * (1 + prof.bestContact * snugBoost); + // Open board in classic: hand out big/long pieces to fill fast. + if (openBoard) w *= 1 + p.cells.length * classicBigPieceBias; + return w; } List draw() { @@ -61,9 +78,29 @@ class PieceGenerator { return tray; } + final attempts = clearFocus ? classicDrawAttempts : traySetDrawAttempts; + + if (clearFocus) { + // Keep the playable set that can clear the most lines. + List? bestSet; + var bestScore = -1; + for (var attempt = 0; attempt < attempts; attempt++) { + final tray = draw(); + if (!canPlaceAllInSomeOrder(board, tray)) continue; + final score = clearingPotential(board, tray); + if (score > bestScore) { + bestScore = score; + bestSet = tray; + // A double-line-or-better set is plenty satisfying; take it. + if (score >= 2) break; + } + } + if (bestSet != null) return bestSet; + } + List? solvableFallback; var best = draw(); - for (var attempt = 0; attempt < traySetDrawAttempts; attempt++) { + for (var attempt = 0; attempt < attempts; attempt++) { final tray = attempt == 0 ? best : draw(); best = tray; if (!canPlaceAllInSomeOrder(board, tray)) continue; diff --git a/lib/systems/placement.dart b/lib/systems/placement.dart index f5fc48a..420b229 100644 --- a/lib/systems/placement.dart +++ b/lib/systems/placement.dart @@ -41,7 +41,12 @@ bool fitsAnywhere(Board board, Piece piece) { /// column — without allocating a stamped board (the hot path in tray /// generation runs this across every piece × position each refill). /// Caller ensures the placement is legal. -bool placementCompletesLine(Board board, Piece piece, int row, int col) { +bool placementCompletesLine(Board board, Piece piece, int row, int col) => + countCompletedLines(board, piece, row, col) > 0; + +/// How many rows + columns placing [piece] at ([row], [col]) would +/// complete simultaneously. Allocation-free. Caller ensures legality. +int countCompletedLines(Board board, Piece piece, int row, int col) { bool isFilled(int r, int c) { if (board.isOccupied(r, c)) return true; for (final (pr, pc) in piece.cells) { @@ -50,9 +55,11 @@ bool placementCompletesLine(Board board, Piece piece, int row, int col) { return false; } - // Only rows/cols the piece touches can newly complete. + var count = 0; + final rowsSeen = {}; for (final (pr, _) in piece.cells) { final r = row + pr; + if (!rowsSeen.add(r)) continue; var full = true; for (var c = 0; c < Board.size; c++) { if (!isFilled(r, c)) { @@ -60,10 +67,12 @@ bool placementCompletesLine(Board board, Piece piece, int row, int col) { break; } } - if (full) return true; + if (full) count++; } + final colsSeen = {}; for (final (_, pc) in piece.cells) { final c = col + pc; + if (!colsSeen.add(c)) continue; var full = true; for (var r = 0; r < Board.size; r++) { if (!isFilled(r, c)) { @@ -71,7 +80,7 @@ bool placementCompletesLine(Board board, Piece piece, int row, int col) { break; } } - if (full) return true; + if (full) count++; } - return false; + return count; } diff --git a/lib/systems/solvability.dart b/lib/systems/solvability.dart index 94185d4..2275f04 100644 --- a/lib/systems/solvability.dart +++ b/lib/systems/solvability.dart @@ -20,6 +20,42 @@ bool canClearLineWith(Board board, Piece piece) { return false; } +/// Greedy estimate of how many lines the [pieces] can clear if played +/// well from [board]: repeatedly place the piece+position clearing the +/// most lines, applying clears between placements. Used to rank candidate +/// trays in classic clear-focus mode — higher = more clears/combos. +int clearingPotential(Board board, List pieces) { + var b = board; + final remaining = [...pieces]; + var total = 0; + while (remaining.isNotEmpty) { + var bestLines = -1; + var bestIndex = -1; + var bestRow = 0; + var bestCol = 0; + for (var i = 0; i < remaining.length; i++) { + final piece = remaining[i]; + for (var r = 0; r <= Board.size - piece.height; r++) { + for (var c = 0; c <= Board.size - piece.width; c++) { + if (!canPlace(b, piece, r, c)) continue; + final lines = countCompletedLines(b, piece, r, c); + if (lines > bestLines) { + bestLines = lines; + bestIndex = i; + bestRow = r; + bestCol = c; + } + } + } + } + if (bestIndex == -1) break; // nothing else fits + total += bestLines; + b = clearFullLines(stamp(b, remaining[bestIndex], bestRow, bestCol)).board; + remaining.removeAt(bestIndex); + } + return total; +} + /// How a piece relates to the current board, computed in one placement /// scan (drives satisfaction-first tray dealing). class FitProfile { diff --git a/test/piece_generator_test.dart b/test/piece_generator_test.dart index 156bb3c..8292fb7 100644 --- a/test/piece_generator_test.dart +++ b/test/piece_generator_test.dart @@ -98,6 +98,61 @@ void main() { expect(breakerTrays / trials, greaterThan(0.9)); }); + test('clearFocus deals more clears than default on a primed board', () { + // Several rows one cell short: lots of breaks available. + final primed = boardFrom([ + '........', + '........', + '.1111111', + '1111111.', + '.1111111', + '1111111.', + '.1111111', + '1111111.', + ]); + var focusClears = 0; + var defaultClears = 0; + const trials = 40; + for (var seed = 0; seed < trials; seed++) { + final f = PieceGenerator( + GameRng(seed), + ).nextTray(primed, clearFocus: true); + final d = PieceGenerator(GameRng(seed)).nextTray(primed); + focusClears += clearingPotential(primed, f); + defaultClears += clearingPotential(primed, d); + } + expect( + focusClears, + greaterThan(defaultClears), + reason: 'clear-focus should yield more clearing potential', + ); + }); + + test('clearFocus favors big pieces on an empty board', () { + final empty = Board.empty(); + var focusCells = 0; + var defaultCells = 0; + const trials = 40; + for (var seed = 0; seed < trials; seed++) { + for (final p in PieceGenerator( + GameRng(seed), + ).nextTray(empty, clearFocus: true)) { + focusCells += p.cells.length; + } + for (final p in PieceGenerator(GameRng(seed)).nextTray(empty)) { + defaultCells += p.cells.length; + } + } + expect(focusCells, greaterThan(defaultCells)); + }); + + test('clearFocus stays deterministic per seed', () { + final board = Board.empty(); + final a = PieceGenerator(GameRng(9)).nextTray(board, clearFocus: true); + final b = PieceGenerator(GameRng(9)).nextTray(board, clearFocus: true); + expect(a.map((p) => p.id).toList(), b.map((p) => p.id).toList()); + }); + 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/placement_test.dart b/test/placement_test.dart index f0af42b..5a1b62e 100644 --- a/test/placement_test.dart +++ b/test/placement_test.dart @@ -74,6 +74,21 @@ void main() { expect(placementCompletesLine(board, single, 3, 3), isFalse); }); + test('counts multiple simultaneous lines', () { + // Placing a single at (7,7) completes both row 7 and column 7. + final board = boardFrom([ + '.......1', + '.......1', + '.......1', + '.......1', + '.......1', + '.......1', + '.......1', + '1111111.', + ]); + expect(countCompletedLines(board, single, 7, 7), 2); + }); + test('agrees with the stamp+clear result across positions', () { final board = boardFrom([ '11111.1.',