0210c14858
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>
136 lines
4.6 KiB
Dart
136 lines
4.6 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|