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 tray) { if (tray.isEmpty) return true; final triedIds = {}; 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? 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 _pool; List nextTray(GridState grid) { for (var attempt = 0; attempt < _maxRedraws; attempt++) { final tray = _draw(grid); if (isTrayPlayable(grid, tray)) return tray; } return _fallback(grid); } List _draw(GridState grid) { final fill = grid.fillRatio; final candidates = List.of(_pool); final tray = []; 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 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 _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 = [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; } }