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,61 @@
|
||||
import 'package:block_seasons/core/rng.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('SeededRng', () {
|
||||
test('same seed produces identical 1000-draw sequence', () {
|
||||
final a = SeededRng(8841273);
|
||||
final b = SeededRng(8841273);
|
||||
for (var i = 0; i < 1000; i++) {
|
||||
expect(a.nextInt(1 << 30), b.nextInt(1 << 30));
|
||||
}
|
||||
});
|
||||
|
||||
test('different seeds produce different sequences', () {
|
||||
final a = SeededRng(1);
|
||||
final b = SeededRng(2);
|
||||
final drawsA = List.generate(20, (_) => a.nextInt(1 << 30));
|
||||
final drawsB = List.generate(20, (_) => b.nextInt(1 << 30));
|
||||
expect(drawsA, isNot(equals(drawsB)));
|
||||
});
|
||||
|
||||
test('nextInt stays within [0, max)', () {
|
||||
final rng = SeededRng(42);
|
||||
for (final max in [1, 2, 3, 7, 40, 1000]) {
|
||||
for (var i = 0; i < 2000; i++) {
|
||||
final v = rng.nextInt(max);
|
||||
expect(v, inInclusiveRange(0, max - 1));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('nextInt eventually covers every value for small max', () {
|
||||
final rng = SeededRng(7);
|
||||
final seen = <int>{};
|
||||
for (var i = 0; i < 1000; i++) {
|
||||
seen.add(rng.nextInt(6));
|
||||
}
|
||||
expect(seen, {0, 1, 2, 3, 4, 5});
|
||||
});
|
||||
|
||||
test('nextDouble stays within [0, 1)', () {
|
||||
final rng = SeededRng(99);
|
||||
for (var i = 0; i < 5000; i++) {
|
||||
final v = rng.nextDouble();
|
||||
expect(v, greaterThanOrEqualTo(0.0));
|
||||
expect(v, lessThan(1.0));
|
||||
}
|
||||
});
|
||||
|
||||
test('fork creates an independent stream that is deterministic', () {
|
||||
final a = SeededRng(123).fork(5);
|
||||
final b = SeededRng(123).fork(5);
|
||||
final c = SeededRng(123).fork(6);
|
||||
final drawsA = List.generate(20, (_) => a.nextInt(1 << 30));
|
||||
final drawsB = List.generate(20, (_) => b.nextInt(1 << 30));
|
||||
final drawsC = List.generate(20, (_) => c.nextInt(1 << 30));
|
||||
expect(drawsA, drawsB);
|
||||
expect(drawsA, isNot(equals(drawsC)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:block_seasons/game/engine/line_clear.dart';
|
||||
import 'package:block_seasons/game/models/cell.dart';
|
||||
import 'package:block_seasons/game/models/grid.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
GridState _fillRow(GridState grid, int y, {Set<int>? skip}) {
|
||||
for (var x = 0; x < GridState.size; x++) {
|
||||
if (skip?.contains(x) ?? false) continue;
|
||||
grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0));
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
GridState _fillCol(GridState grid, int x, {Set<int>? skip}) {
|
||||
for (var y = 0; y < GridState.size; y++) {
|
||||
if (skip?.contains(y) ?? false) continue;
|
||||
grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0));
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('detectAndClear', () {
|
||||
test('no full lines leaves the grid untouched', () {
|
||||
final grid = _fillRow(GridState.empty(), 3, skip: {5});
|
||||
final result = detectAndClear(grid);
|
||||
expect(result.clearedRows, isEmpty);
|
||||
expect(result.clearedCols, isEmpty);
|
||||
expect(result.linesCleared, 0);
|
||||
expect(result.gemsCleared, 0);
|
||||
expect(result.grid.occupiedCount, grid.occupiedCount);
|
||||
});
|
||||
|
||||
test('clears a single full row', () {
|
||||
final grid = _fillRow(GridState.empty(), 2);
|
||||
final result = detectAndClear(grid);
|
||||
expect(result.clearedRows, [2]);
|
||||
expect(result.clearedCols, isEmpty);
|
||||
expect(result.linesCleared, 1);
|
||||
expect(result.grid.occupiedCount, 0);
|
||||
});
|
||||
|
||||
test('clears a single full column', () {
|
||||
final grid = _fillCol(GridState.empty(), 6);
|
||||
final result = detectAndClear(grid);
|
||||
expect(result.clearedRows, isEmpty);
|
||||
expect(result.clearedCols, [6]);
|
||||
expect(result.grid.occupiedCount, 0);
|
||||
});
|
||||
|
||||
test('clears two rows at once', () {
|
||||
var grid = _fillRow(GridState.empty(), 0);
|
||||
grid = _fillRow(grid, 7);
|
||||
final result = detectAndClear(grid);
|
||||
expect(result.clearedRows, [0, 7]);
|
||||
expect(result.linesCleared, 2);
|
||||
expect(result.grid.occupiedCount, 0);
|
||||
});
|
||||
|
||||
test('row and column sharing a corner clear together', () {
|
||||
var grid = _fillRow(GridState.empty(), 4);
|
||||
grid = _fillCol(grid, 4);
|
||||
final result = detectAndClear(grid);
|
||||
expect(result.clearedRows, [4]);
|
||||
expect(result.clearedCols, [4]);
|
||||
expect(result.linesCleared, 2);
|
||||
// 8 + 8 - 1 shared cell were occupied; all gone now.
|
||||
expect(result.grid.occupiedCount, 0);
|
||||
});
|
||||
|
||||
test('counts gems in cleared lines, intersection gem only once', () {
|
||||
var grid = GridState.empty();
|
||||
grid = grid.withCell(4, 4, const Cell(CellType.gem));
|
||||
grid = grid.withCell(0, 4, const Cell(CellType.gem));
|
||||
grid = grid.withCell(4, 0, const Cell(CellType.gem));
|
||||
grid = _fillRow(grid, 4, skip: {0, 4});
|
||||
grid = _fillCol(grid, 4, skip: {0, 4});
|
||||
final result = detectAndClear(grid);
|
||||
expect(result.clearedRows, [4]);
|
||||
expect(result.clearedCols, [4]);
|
||||
// Gems at (0,4), (4,0), and the shared (4,4) -> exactly 3.
|
||||
expect(result.gemsCleared, 3);
|
||||
});
|
||||
|
||||
test('cells outside cleared lines survive', () {
|
||||
var grid = _fillRow(GridState.empty(), 1);
|
||||
grid = grid.withCell(3, 5, const Cell(CellType.filled, colorId: 2));
|
||||
final result = detectAndClear(grid);
|
||||
expect(result.grid.occupiedCount, 1);
|
||||
expect(result.grid.cellAt(3, 5).colorId, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:block_seasons/core/rng.dart';
|
||||
import 'package:block_seasons/game/engine/piece_generator.dart';
|
||||
import 'package:block_seasons/game/models/cell.dart';
|
||||
import 'package:block_seasons/game/models/grid.dart';
|
||||
import 'package:block_seasons/game/models/piece_library.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Checkerboard: no line can ever be completed, big shapes never fit.
|
||||
GridState _checkerboard() {
|
||||
var grid = GridState.empty();
|
||||
for (var y = 0; y < GridState.size; y++) {
|
||||
for (var x = 0; x < GridState.size; x++) {
|
||||
if ((x + y).isEven) {
|
||||
grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
/// Rows 1..7 filled except column 0; row 0 filled except (0,0).
|
||||
/// Only column 0 is free -> tight but recoverable via line clears.
|
||||
GridState _tightBoard() {
|
||||
var grid = GridState.empty();
|
||||
for (var y = 0; y < GridState.size; y++) {
|
||||
for (var x = 1; x < GridState.size; x++) {
|
||||
grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0));
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('isTrayPlayable', () {
|
||||
test('any tray plays out on an empty grid', () {
|
||||
final tray = [
|
||||
PieceLibrary.byId('square3'),
|
||||
PieceLibrary.byId('line5_h'),
|
||||
PieceLibrary.byId('rect3x2'),
|
||||
];
|
||||
expect(isTrayPlayable(GridState.empty(), tray), isTrue);
|
||||
});
|
||||
|
||||
test('recognizes trays that need intermediate line clears', () {
|
||||
// Column 0 free (8 cells); tray sums to 9 cells, so it only plays out
|
||||
// if placements trigger clears that open space.
|
||||
final tray = [
|
||||
PieceLibrary.byId('line4_v'),
|
||||
PieceLibrary.byId('line4_v'),
|
||||
PieceLibrary.byId('mono'),
|
||||
];
|
||||
expect(isTrayPlayable(_tightBoard(), tray), isTrue);
|
||||
});
|
||||
|
||||
test('returns false when no ordering can play all pieces', () {
|
||||
final tray = [
|
||||
PieceLibrary.byId('square3'),
|
||||
PieceLibrary.byId('square3'),
|
||||
PieceLibrary.byId('line5_h'),
|
||||
];
|
||||
expect(isTrayPlayable(_checkerboard(), tray), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('PieceGenerator', () {
|
||||
test('same seed produces identical tray sequences', () {
|
||||
final a = PieceGenerator(SeededRng(42));
|
||||
final b = PieceGenerator(SeededRng(42));
|
||||
final grid = GridState.empty();
|
||||
for (var i = 0; i < 100; i++) {
|
||||
final trayA = a.nextTray(grid).map((p) => p.id).toList();
|
||||
final trayB = b.nextTray(grid).map((p) => p.id).toList();
|
||||
expect(trayA, trayB);
|
||||
}
|
||||
});
|
||||
|
||||
test('trays always contain exactly 3 pieces with distinct ids', () {
|
||||
final gen = PieceGenerator(SeededRng(7));
|
||||
final grids = [GridState.empty(), _checkerboard(), _tightBoard()];
|
||||
for (final grid in grids) {
|
||||
for (var i = 0; i < 200; i++) {
|
||||
final tray = gen.nextTray(grid);
|
||||
expect(tray.length, 3);
|
||||
expect(tray.map((p) => p.id).toSet().length, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('pity bias: tighter boards get smaller pieces on average', () {
|
||||
final emptyGen = PieceGenerator(SeededRng(123));
|
||||
final tightGen = PieceGenerator(SeededRng(123));
|
||||
final empty = GridState.empty();
|
||||
final tight = _checkerboard(); // 50% fill, no clears possible
|
||||
|
||||
double meanSize(PieceGenerator gen, GridState grid) {
|
||||
var total = 0;
|
||||
for (var i = 0; i < 400; i++) {
|
||||
for (final p in gen.nextTray(grid)) {
|
||||
total += p.size;
|
||||
}
|
||||
}
|
||||
return total / (400 * 3);
|
||||
}
|
||||
|
||||
final emptyMean = meanSize(emptyGen, empty);
|
||||
final tightMean = meanSize(tightGen, tight);
|
||||
expect(tightMean, lessThan(emptyMean));
|
||||
});
|
||||
|
||||
test('nudge: tight-but-recoverable boards always get playable trays',
|
||||
() {
|
||||
final gen = PieceGenerator(SeededRng(2026));
|
||||
final grid = _tightBoard();
|
||||
for (var i = 0; i < 100; i++) {
|
||||
final tray = gen.nextTray(grid);
|
||||
expect(isTrayPlayable(grid, tray), isTrue,
|
||||
reason: 'deal #$i: ${tray.map((p) => p.id).join(", ")}');
|
||||
}
|
||||
});
|
||||
|
||||
test('on a dead board the tray still contains the smallest pieces', () {
|
||||
// Fully occupied: nothing fits; engine handles game over, but the
|
||||
// generator must not loop forever and should fall back to small pieces.
|
||||
var grid = GridState.empty();
|
||||
for (var y = 0; y < GridState.size; y++) {
|
||||
for (var x = 0; x < GridState.size; x++) {
|
||||
grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0));
|
||||
}
|
||||
}
|
||||
final tray = PieceGenerator(SeededRng(5)).nextTray(grid);
|
||||
expect(tray.length, 3);
|
||||
expect(tray.every((p) => p.size <= 2), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:block_seasons/game/engine/placement.dart';
|
||||
import 'package:block_seasons/game/models/cell.dart';
|
||||
import 'package:block_seasons/game/models/grid.dart';
|
||||
import 'package:block_seasons/game/models/piece.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const mono = Piece(id: 'mono', colorId: 0, offsets: [(0, 0)]);
|
||||
const dominoH = Piece(id: 'domino_h', colorId: 1, offsets: [(0, 0), (1, 0)]);
|
||||
const square2 = Piece(
|
||||
id: 'square2',
|
||||
colorId: 2,
|
||||
offsets: [(0, 0), (1, 0), (0, 1), (1, 1)],
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('canPlace', () {
|
||||
test('any piece fits anywhere legal on an empty grid', () {
|
||||
final grid = GridState.empty();
|
||||
expect(canPlace(grid, mono, 0, 0), isTrue);
|
||||
expect(canPlace(grid, mono, 7, 7), isTrue);
|
||||
expect(canPlace(grid, square2, 6, 6), isTrue);
|
||||
});
|
||||
|
||||
test('rejects out-of-bounds placements', () {
|
||||
final grid = GridState.empty();
|
||||
expect(canPlace(grid, square2, 7, 7), isFalse);
|
||||
expect(canPlace(grid, dominoH, 7, 0), isFalse);
|
||||
expect(canPlace(grid, mono, -1, 0), isFalse);
|
||||
expect(canPlace(grid, mono, 0, 8), isFalse);
|
||||
});
|
||||
|
||||
test('rejects overlap with occupied cells', () {
|
||||
final grid = GridState.empty()
|
||||
.withCell(1, 0, const Cell(CellType.filled, colorId: 0));
|
||||
expect(canPlace(grid, dominoH, 0, 0), isFalse);
|
||||
expect(canPlace(grid, dominoH, 2, 0), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('place', () {
|
||||
test('fills cells with the piece color and keeps original grid', () {
|
||||
final grid = GridState.empty();
|
||||
final next = place(grid, square2, 3, 3);
|
||||
|
||||
expect(grid.occupiedCount, 0);
|
||||
expect(next.occupiedCount, 4);
|
||||
for (final (dx, dy) in square2.offsets) {
|
||||
expect(next.cellAt(3 + dx, 3 + dy).type, CellType.filled);
|
||||
expect(next.cellAt(3 + dx, 3 + dy).colorId, square2.colorId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('anyPlacementExists', () {
|
||||
test('true on an empty grid', () {
|
||||
expect(anyPlacementExists(GridState.empty(), [square2]), isTrue);
|
||||
});
|
||||
|
||||
test('detects when only a small piece fits', () {
|
||||
// Fill everything except (7, 7).
|
||||
var grid = GridState.empty();
|
||||
for (var y = 0; y < GridState.size; y++) {
|
||||
for (var x = 0; x < GridState.size; x++) {
|
||||
if (x == 7 && y == 7) continue;
|
||||
grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0));
|
||||
}
|
||||
}
|
||||
expect(anyPlacementExists(grid, [mono]), isTrue);
|
||||
expect(anyPlacementExists(grid, [dominoH]), isFalse);
|
||||
expect(anyPlacementExists(grid, [dominoH, mono]), isTrue);
|
||||
});
|
||||
|
||||
test('false when the grid is completely full', () {
|
||||
var grid = GridState.empty();
|
||||
for (var y = 0; y < GridState.size; y++) {
|
||||
for (var x = 0; x < GridState.size; x++) {
|
||||
grid = grid.withCell(x, y, const Cell(CellType.filled, colorId: 0));
|
||||
}
|
||||
}
|
||||
expect(anyPlacementExists(grid, [mono]), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:block_seasons/game/engine/scoring.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('lineClearBase', () {
|
||||
test('escalates with simultaneous lines', () {
|
||||
expect(lineClearBase(1), 100);
|
||||
expect(lineClearBase(2), 300);
|
||||
expect(lineClearBase(3), 600);
|
||||
expect(lineClearBase(4), 1000);
|
||||
});
|
||||
});
|
||||
|
||||
group('comboMultiplier', () {
|
||||
test('grows by 0.5 per streak step and caps at 8', () {
|
||||
expect(comboMultiplier(1), 1.5);
|
||||
expect(comboMultiplier(2), 2.0);
|
||||
expect(comboMultiplier(8), 5.0);
|
||||
expect(comboMultiplier(12), 5.0);
|
||||
});
|
||||
});
|
||||
|
||||
group('ComboState', () {
|
||||
test('clearing placements grow the streak', () {
|
||||
var combo = ComboState.initial;
|
||||
combo = combo.advance(cleared: true);
|
||||
expect(combo.streak, 1);
|
||||
combo = combo.advance(cleared: true);
|
||||
expect(combo.streak, 2);
|
||||
});
|
||||
|
||||
test('one dry move is grace, streak survives', () {
|
||||
var combo = ComboState.initial;
|
||||
combo = combo.advance(cleared: true);
|
||||
combo = combo.advance(cleared: false);
|
||||
expect(combo.streak, 1);
|
||||
combo = combo.advance(cleared: true);
|
||||
expect(combo.streak, 2);
|
||||
});
|
||||
|
||||
test('two consecutive dry moves reset the streak', () {
|
||||
var combo = ComboState.initial;
|
||||
combo = combo.advance(cleared: true);
|
||||
combo = combo.advance(cleared: false);
|
||||
combo = combo.advance(cleared: false);
|
||||
expect(combo.streak, 0);
|
||||
combo = combo.advance(cleared: true);
|
||||
expect(combo.streak, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('scorePlacement', () {
|
||||
test('placement without clear scores cell count only', () {
|
||||
final delta = scorePlacement(
|
||||
cellsPlaced: 4,
|
||||
linesCleared: 0,
|
||||
combo: ComboState.initial,
|
||||
);
|
||||
expect(delta.points, 4);
|
||||
expect(delta.combo.streak, 0);
|
||||
});
|
||||
|
||||
test('first clear applies x1.5 multiplier', () {
|
||||
final delta = scorePlacement(
|
||||
cellsPlaced: 5,
|
||||
linesCleared: 1,
|
||||
combo: ComboState.initial,
|
||||
);
|
||||
// 5 + round(100 * 1.5) = 155
|
||||
expect(delta.points, 155);
|
||||
expect(delta.combo.streak, 1);
|
||||
});
|
||||
|
||||
test('streak compounds across placements', () {
|
||||
var combo = ComboState.initial;
|
||||
final first = scorePlacement(
|
||||
cellsPlaced: 3,
|
||||
linesCleared: 1,
|
||||
combo: combo,
|
||||
);
|
||||
combo = first.combo;
|
||||
final second = scorePlacement(
|
||||
cellsPlaced: 3,
|
||||
linesCleared: 2,
|
||||
combo: combo,
|
||||
);
|
||||
// 3 + round(300 * 2.0) = 603
|
||||
expect(second.points, 603);
|
||||
expect(second.combo.streak, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user