41c18c8bdd
Greedy bot with tray-survival lookahead and gem-line steering; generator samples layouts along a difficulty curve, probes bot moves-to-win, sets adaptive move budgets targeting win-rate bands, and derives star thresholds from spare-move quantiles. Season 1 pack bundled in assets with per-stage difficulty report. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
108 lines
3.4 KiB
Dart
108 lines
3.4 KiB
Dart
import '../../core/rng.dart';
|
|
import '../models/cell.dart';
|
|
import '../models/grid.dart';
|
|
import '../models/stage.dart';
|
|
import 'game_engine.dart';
|
|
import 'line_clear.dart';
|
|
import 'piece_generator.dart';
|
|
import 'placement.dart';
|
|
import 'scoring.dart';
|
|
|
|
class BotRun {
|
|
const BotRun({
|
|
required this.won,
|
|
required this.movesUsed,
|
|
required this.movesLeft,
|
|
required this.stars,
|
|
required this.score,
|
|
});
|
|
|
|
final bool won;
|
|
final int movesUsed;
|
|
final int movesLeft;
|
|
final int stars;
|
|
final int score;
|
|
}
|
|
|
|
/// Greedy objective-seeking bot used by the stage generator to calibrate
|
|
/// difficulty (win rates, move budgets) against the real shipping engine.
|
|
/// It never uses rescues, so its results reflect a clean attempt.
|
|
class AutoPlayer {
|
|
BotRun play(StageConfig stage, {int attempt = 0}) {
|
|
final engine = GameEngine(stage, attempt: attempt);
|
|
final tieBreaker = SeededRng(stage.seed ^ (attempt * 31 + 7));
|
|
var guard = 0;
|
|
|
|
while (engine.phase == GamePhase.playing && guard < 400) {
|
|
guard++;
|
|
final moves = <(double, int, int, int)>[];
|
|
|
|
for (var i = 0; i < engine.tray.length; i++) {
|
|
final piece = engine.tray[i];
|
|
for (var y = 0; y < GridState.size; y++) {
|
|
for (var x = 0; x < GridState.size; x++) {
|
|
if (!canPlace(engine.grid, piece, x, y)) continue;
|
|
final clear = detectAndClear(place(engine.grid, piece, x, y));
|
|
final h = clear.gemsCleared * 120.0 +
|
|
lineClearBase(clear.linesCleared) * 0.5 +
|
|
piece.size -
|
|
clear.grid.fillRatio * 10 +
|
|
_gemLineProgress(clear.grid) * 30 +
|
|
tieBreaker.nextDouble() * 1e-6;
|
|
moves.add((h, i, x, y));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (moves.isEmpty) break;
|
|
moves.sort((a, b) => b.$1.compareTo(a.$1));
|
|
|
|
// Greedy alone walls itself in: prefer the best-ranked move that
|
|
// leaves the rest of the tray playable (with clears resolved).
|
|
var chosen = moves.first;
|
|
for (final move in moves.take(10)) {
|
|
final (_, i, x, y) = move;
|
|
final after =
|
|
detectAndClear(place(engine.grid, engine.tray[i], x, y)).grid;
|
|
final rest = [
|
|
for (var k = 0; k < engine.tray.length; k++)
|
|
if (k != i) engine.tray[k],
|
|
];
|
|
if (isTrayPlayable(after, rest)) {
|
|
chosen = move;
|
|
break;
|
|
}
|
|
}
|
|
engine.tryPlace(chosen.$2, chosen.$3, chosen.$4);
|
|
}
|
|
|
|
return BotRun(
|
|
won: engine.phase == GamePhase.won,
|
|
movesUsed: engine.movesUsed,
|
|
movesLeft: engine.movesLeft,
|
|
stars: engine.starsEarned,
|
|
score: engine.score,
|
|
);
|
|
}
|
|
|
|
/// How close remaining gems are to being cleared: for each gem, the best
|
|
/// fill fraction of its row or column. Steers the bot toward building the
|
|
/// lines that matter.
|
|
double _gemLineProgress(GridState grid) {
|
|
var progress = 0.0;
|
|
for (var y = 0; y < GridState.size; y++) {
|
|
for (var x = 0; x < GridState.size; x++) {
|
|
if (grid.cellAt(x, y).type != CellType.gem) continue;
|
|
var rowFill = 0;
|
|
var colFill = 0;
|
|
for (var i = 0; i < GridState.size; i++) {
|
|
if (grid.isOccupied(i, y)) rowFill++;
|
|
if (grid.isOccupied(x, i)) colFill++;
|
|
}
|
|
progress += (rowFill > colFill ? rowFill : colFill) / GridState.size;
|
|
}
|
|
}
|
|
return progress;
|
|
}
|
|
}
|