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:
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user