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); }); }); }