Add pure-Dart engine core: RNG, grid, placement, line clear, scoring, piece generator

PCG32 seeded RNG; immutable 8x8 GridState with occupancy bitmask;
placement legality + anyPlacementExists; simultaneous row/col clears
with single-count gem credit; combo scoring with one-move grace;
weighted-bag generator with pity bias and depth-3 solvability nudge.
All TDD, 51 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:05:55 +09:00
parent 40528238b2
commit 0210c14858
19 changed files with 1408 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
import '../models/cell.dart';
import '../models/grid.dart';
class ClearResult {
const ClearResult({
required this.grid,
required this.clearedRows,
required this.clearedCols,
required this.gemsCleared,
});
final GridState grid;
final List<int> clearedRows;
final List<int> clearedCols;
final int gemsCleared;
int get linesCleared => clearedRows.length + clearedCols.length;
}
/// Detects all simultaneously full rows/columns and clears them in one pass.
ClearResult detectAndClear(GridState grid) {
final rows = <int>[];
final cols = <int>[];
for (var y = 0; y < GridState.size; y++) {
if (grid.isRowFull(y)) rows.add(y);
}
for (var x = 0; x < GridState.size; x++) {
if (grid.isColFull(x)) cols.add(x);
}
if (rows.isEmpty && cols.isEmpty) {
return ClearResult(
grid: grid,
clearedRows: const [],
clearedCols: const [],
gemsCleared: 0,
);
}
final rowSet = rows.toSet();
final colSet = cols.toSet();
var gems = 0;
var next = grid;
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (!rowSet.contains(y) && !colSet.contains(x)) continue;
if (grid.cellAt(x, y).type == CellType.gem) gems++;
next = next.withCell(x, y, Cell.empty);
}
}
return ClearResult(
grid: next,
clearedRows: rows,
clearedCols: cols,
gemsCleared: gems,
);
}
+117
View File
@@ -0,0 +1,117 @@
import '../../core/rng.dart';
import '../models/grid.dart';
import '../models/piece.dart';
import '../models/piece_library.dart';
import 'line_clear.dart';
import 'placement.dart';
/// Whether some ordering of [tray] can be fully played out (with line clears
/// resolved between placements). Depth-3 backtracking on an 8x8 is cheap.
bool isTrayPlayable(GridState grid, List<Piece> tray) {
if (tray.isEmpty) return true;
final triedIds = <String>{};
for (var i = 0; i < tray.length; i++) {
final piece = tray[i];
if (!triedIds.add(piece.id)) continue;
final rest = [...tray]..removeAt(i);
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (!canPlace(grid, piece, x, y)) continue;
final after = detectAndClear(place(grid, piece, x, y)).grid;
if (isTrayPlayable(after, rest)) return true;
}
}
}
return false;
}
/// Weighted-bag tray dealer with pity bias on tight boards and a solvability
/// nudge: the game never deals an instantly dead tray when a playable deal
/// exists, but bad play can still lose.
class PieceGenerator {
PieceGenerator(this._rng, {List<Piece>? pool})
: _pool = pool ?? PieceLibrary.all;
static const int traySize = 3;
static const int _maxRedraws = 5;
static const double _pityThreshold = 0.6;
final SeededRng _rng;
final List<Piece> _pool;
List<Piece> nextTray(GridState grid) {
for (var attempt = 0; attempt < _maxRedraws; attempt++) {
final tray = _draw(grid);
if (isTrayPlayable(grid, tray)) return tray;
}
return _fallback(grid);
}
List<Piece> _draw(GridState grid) {
final fill = grid.fillRatio;
final candidates = List.of(_pool);
final tray = <Piece>[];
while (tray.length < traySize) {
final pick = _weightedPick(candidates, fill);
tray.add(pick);
candidates.removeWhere((p) => p.id == pick.id);
}
return tray;
}
/// Above 60% board fill, small pieces get progressively more likely so the
/// late game feels fair instead of dealing unplaceable monsters.
double _effectiveWeight(Piece piece, double fill) {
var w = piece.weight;
if (fill > _pityThreshold && piece.size <= 3) {
w *= 1 + 2 * (fill - _pityThreshold);
}
return w;
}
Piece _weightedPick(List<Piece> candidates, double fill) {
var total = 0.0;
for (final p in candidates) {
total += _effectiveWeight(p, fill);
}
var r = _rng.nextDouble() * total;
for (final p in candidates) {
r -= _effectiveWeight(p, fill);
if (r < 0) return p;
}
return candidates.last;
}
/// After repeated unplayable draws: the largest piece that still fits plus
/// the smallest fillers. Consumes no RNG, so retries stay deterministic.
List<Piece> _fallback(GridState grid) {
final bySizeDesc = [..._pool]
..sort((a, b) {
final s = b.size.compareTo(a.size);
return s != 0 ? s : a.id.compareTo(b.id);
});
Piece? largestFitting;
for (final p in bySizeDesc) {
if (_fitsAnywhere(grid, p)) {
largestFitting = p;
break;
}
}
final tray = <Piece>[if (largestFitting != null) largestFitting];
for (final p in bySizeDesc.reversed) {
if (tray.length == traySize) break;
if (tray.any((t) => t.id == p.id)) continue;
tray.add(p);
}
return tray;
}
bool _fitsAnywhere(GridState grid, Piece piece) {
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (canPlace(grid, piece, x, y)) return true;
}
}
return false;
}
}
+41
View File
@@ -0,0 +1,41 @@
import '../models/cell.dart';
import '../models/grid.dart';
import '../models/piece.dart';
/// Whether [piece] fits with its anchor at ([x], [y]).
bool canPlace(GridState grid, Piece piece, int x, int y) {
for (final (dx, dy) in piece.offsets) {
final px = x + dx;
final py = y + dy;
if (px < 0 || px >= GridState.size || py < 0 || py >= GridState.size) {
return false;
}
if (grid.isOccupied(px, py)) return false;
}
return true;
}
/// Places [piece] at ([x], [y]); caller must check [canPlace] first.
GridState place(GridState grid, Piece piece, int x, int y) {
var next = grid;
for (final (dx, dy) in piece.offsets) {
next = next.withCell(
x + dx,
y + dy,
Cell(CellType.filled, colorId: piece.colorId),
);
}
return next;
}
/// Whether at least one of [pieces] has at least one legal placement.
bool anyPlacementExists(GridState grid, List<Piece> pieces) {
for (final piece in pieces) {
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (canPlace(grid, piece, x, y)) return true;
}
}
}
return false;
}
+50
View File
@@ -0,0 +1,50 @@
/// Pure scoring math: placement points, line-clear bonuses, combo streaks.
library;
/// Base score for clearing [lines] rows/columns simultaneously.
/// 1 -> 100, 2 -> 300, 3 -> 600, 4 -> 1000: simultaneous clears are
/// superlinearly rewarded.
int lineClearBase(int lines) => 100 * lines + 50 * (lines - 1) * lines;
/// Multiplier applied to clear score at combo [streak], capped at streak 8
/// (x5.0) so late-game scores stay bounded.
double comboMultiplier(int streak) => 1 + 0.5 * (streak > 8 ? 8 : streak);
/// Combo streak with one dry-move grace before reset.
class ComboState {
const ComboState({required this.streak, required this.dryMoves});
static const initial = ComboState(streak: 0, dryMoves: 0);
final int streak;
final int dryMoves;
/// One dry move keeps the streak alive (grace); the second resets it.
ComboState advance({required bool cleared}) {
if (cleared) return ComboState(streak: streak + 1, dryMoves: 0);
if (dryMoves + 1 >= 2) return initial;
return ComboState(streak: streak, dryMoves: dryMoves + 1);
}
}
class ScoreDelta {
const ScoreDelta({required this.points, required this.combo});
final int points;
final ComboState combo;
}
/// Computes the score delta for one placement and the resulting combo state.
ScoreDelta scorePlacement({
required int cellsPlaced,
required int linesCleared,
required ComboState combo,
}) {
final next = combo.advance(cleared: linesCleared > 0);
var points = cellsPlaced;
if (linesCleared > 0) {
points += (lineClearBase(linesCleared) * comboMultiplier(next.streak))
.round();
}
return ScoreDelta(points: points, combo: next);
}