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
+67
View File
@@ -0,0 +1,67 @@
import 'package:block_seasons/game/models/cell.dart';
import 'package:block_seasons/game/models/grid.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('GridState', () {
test('empty grid has no occupied cells', () {
final grid = GridState.empty();
expect(grid.occupiedCount, 0);
expect(grid.fillRatio, 0.0);
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
expect(grid.cellAt(x, y).type, CellType.empty);
expect(grid.isOccupied(x, y), isFalse);
}
}
});
test('withCell returns a new grid and leaves the original unchanged', () {
final grid = GridState.empty();
final next = grid.withCell(3, 4, const Cell(CellType.filled, colorId: 2));
expect(grid.isOccupied(3, 4), isFalse);
expect(next.isOccupied(3, 4), isTrue);
expect(next.cellAt(3, 4).type, CellType.filled);
expect(next.cellAt(3, 4).colorId, 2);
expect(next.occupiedCount, 1);
});
test('gem cells count as occupied', () {
final grid =
GridState.empty().withCell(0, 0, const Cell(CellType.gem));
expect(grid.isOccupied(0, 0), isTrue);
expect(grid.occupiedCount, 1);
});
test('clearing a cell back to empty updates occupancy', () {
final grid = GridState.empty()
.withCell(5, 5, const Cell(CellType.filled, colorId: 1))
.withCell(5, 5, const Cell(CellType.empty));
expect(grid.isOccupied(5, 5), isFalse);
expect(grid.occupiedCount, 0);
});
test('isRowFull and isColFull detect complete lines', () {
var grid = GridState.empty();
for (var x = 0; x < GridState.size; x++) {
grid = grid.withCell(x, 2, const Cell(CellType.filled, colorId: 0));
}
for (var y = 0; y < GridState.size; y++) {
grid = grid.withCell(6, y, const Cell(CellType.filled, colorId: 0));
}
expect(grid.isRowFull(2), isTrue);
expect(grid.isRowFull(3), isFalse);
expect(grid.isColFull(6), isTrue);
expect(grid.isColFull(0), isFalse);
});
test('fillRatio reflects occupied fraction', () {
var grid = GridState.empty();
for (var x = 0; x < GridState.size; x++) {
grid = grid.withCell(x, 0, const Cell(CellType.filled, colorId: 0));
}
expect(grid.fillRatio, closeTo(8 / 64, 1e-9));
});
});
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:block_seasons/game/models/piece.dart';
import 'package:block_seasons/game/models/piece_library.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PieceLibrary', () {
test('has a rich fixed-orientation shape set', () {
expect(PieceLibrary.all.length, greaterThanOrEqualTo(35));
});
test('ids are unique', () {
final ids = PieceLibrary.all.map((p) => p.id).toSet();
expect(ids.length, PieceLibrary.all.length);
});
test('every piece is normalized to its top-left bounding box', () {
for (final piece in PieceLibrary.all) {
final minDx =
piece.offsets.map((o) => o.$1).reduce((a, b) => a < b ? a : b);
final minDy =
piece.offsets.map((o) => o.$2).reduce((a, b) => a < b ? a : b);
expect(minDx, 0, reason: '${piece.id} not normalized in x');
expect(minDy, 0, reason: '${piece.id} not normalized in y');
}
});
test('offsets are unique and fit in a 5x5 bounding box', () {
for (final piece in PieceLibrary.all) {
expect(piece.offsets.toSet().length, piece.offsets.length,
reason: '${piece.id} has duplicate offsets');
for (final (dx, dy) in piece.offsets) {
expect(dx, inInclusiveRange(0, 4), reason: piece.id);
expect(dy, inInclusiveRange(0, 4), reason: piece.id);
}
}
});
test('weights are positive and tiers are 1..3', () {
for (final piece in PieceLibrary.all) {
expect(piece.weight, greaterThan(0), reason: piece.id);
expect(piece.tier, inInclusiveRange(1, 3), reason: piece.id);
}
});
test('contains the staple shapes', () {
for (final id in ['mono', 'square2', 'square3', 'line5_h', 'line5_v']) {
expect(PieceLibrary.byId(id), isA<Piece>());
}
});
test('byId throws on unknown id', () {
expect(() => PieceLibrary.byId('nope'), throwsStateError);
});
test('small pieces exist for tight late-game boards', () {
final smalls = PieceLibrary.all.where((p) => p.size <= 2);
expect(smalls.length, greaterThanOrEqualTo(3));
});
});
}