Files
BlockSeasons/lib/game/engine/auto_player.dart
T
airkjw 41c18c8bdd Add AutoPlayer bot, stage generator CLI, and calibrated Season 1 (60 stages)
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>
2026-06-11 13:56:54 +09:00

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