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>
This commit is contained in:
2026-06-11 13:56:54 +09:00
parent ad6689b42f
commit 41c18c8bdd
10 changed files with 9289 additions and 1 deletions
+266
View File
@@ -0,0 +1,266 @@
// Season pack generator: builds stages along a difficulty curve, then
// calibrates each one by making the AutoPlayer bot play it repeatedly with
// the real shipping engine.
//
// dart run tool/stage_generator/generate.dart content/season_001/spec.json
//
// Outputs pack.json next to the spec (and copies into assets/ if the spec
// says so) plus a human-readable report.md with per-stage win rates.
import 'dart:convert';
import 'dart:io';
import 'package:block_seasons/core/rng.dart';
import 'package:block_seasons/game/engine/auto_player.dart';
import 'package:block_seasons/game/models/cell.dart';
import 'package:block_seasons/game/models/grid.dart';
import 'package:block_seasons/game/models/objective.dart';
import 'package:block_seasons/game/models/season.dart';
import 'package:block_seasons/game/models/stage.dart';
void main(List<String> args) {
if (args.isEmpty) {
stderr.writeln('usage: dart run tool/stage_generator/generate.dart <spec.json>');
exit(64);
}
final specFile = File(args[0]);
final spec = jsonDecode(specFile.readAsStringSync()) as Map<String, dynamic>;
final outDir = specFile.parent;
final seasonId = spec['seasonId'] as String;
final stageCount = spec['stageCount'] as int;
final baseSeed = spec['baseSeed'] as int;
final runs = (spec['runsPerStage'] as int?) ?? 80;
final bot = AutoPlayer();
final stages = <StageConfig>[];
final reportRows = <String>[];
final sw = Stopwatch()..start();
for (var i = 0; i < stageCount; i++) {
final t = stageCount == 1 ? 0.0 : i / (stageCount - 1);
StageConfig? accepted;
String? rowNote;
for (var layout = 0; layout < 6 && accepted == null; layout++) {
final rng = SeededRng(baseSeed ^ (i * 7919) ^ (layout * 104729));
final candidate = _buildCandidate(seasonId, i, t, rng, bot, runs);
if (candidate == null) continue;
final (stage, winRate, movesLeftWins) = candidate;
final band = _band(t);
if (winRate < band.$1 || winRate > band.$2) {
rowNote = 'rejected layout $layout: winRate ${_pct(winRate)}';
continue;
}
accepted = stage;
reportRows.add(
'| ${stage.id} | ${_objLabel(stage)} | ${stage.moveLimit} | '
'${_pct(winRate)} | ${stage.stars.twoMovesLeft}/${stage.stars.threeMovesLeft} |',
);
}
if (accepted == null) {
stderr.writeln('stage $i: no acceptable layout (${rowNote ?? "?"})');
exit(1);
}
stages.add(accepted);
stdout.writeln(
'stage ${i + 1}/$stageCount calibrated (${sw.elapsed.inSeconds}s)');
}
final pack = SeasonPack(
schemaVersion: 1,
seasonId: seasonId,
version: (spec['version'] as int?) ?? 1,
title: (spec['title'] as Map<String, dynamic>)
.map((k, v) => MapEntry(k, v as String)),
theme: SeasonTheme.fromJson(spec['theme'] as Map<String, dynamic>),
stages: stages,
);
const encoder = JsonEncoder.withIndent(' ');
File('${outDir.path}/pack.json')
.writeAsStringSync(encoder.convert(pack.toJson()));
final copyTo = spec['copyToAssets'] as String?;
if (copyTo != null) {
File(copyTo)
..parent.createSync(recursive: true)
..writeAsStringSync(encoder.convert(pack.toJson()));
}
File('${outDir.path}/report.md').writeAsStringSync('''
# $seasonId difficulty report
${stages.length} stages, $runs bot runs each, generated in ${sw.elapsed.inSeconds}s.
| stage | objective | moves | bot win rate | 2★/3★ movesLeft |
|---|---|---|---|---|
${reportRows.join('\n')}
''');
stdout.writeln('pack.json + report.md written to ${outDir.path}');
}
/// Target bot win-rate band: generous early, tight late. Human players are
/// weaker than the bot, so the floor stays high — and early stages are
/// allowed to be a guaranteed win (onboarding).
(double, double) _band(double t) => (
0.78 - 0.30 * t,
t < 0.4 ? 1.0 : 1.0 - 0.10 * (t - 0.4) / 0.6,
);
(StageConfig, double, List<int>)? _buildCandidate(
String seasonId,
int index,
double t,
SeededRng rng,
AutoPlayer bot,
int runs,
) {
final stageSeed = rng.nextInt(1 << 30);
final preset = _samplePreset(t, rng);
final objective = _sampleObjective(index, t, preset, rng);
// Probe with an effectively unlimited move budget to learn how many moves
// the bot needs, then clamp the budget to set difficulty.
final probe = StageConfig(
id: '${seasonId}_${(index + 1).toString().padLeft(3, '0')}',
seed: stageSeed,
moveLimit: 90,
preset: preset,
objectives: [objective],
stars: const StarThresholds(twoMovesLeft: 1, threeMovesLeft: 2),
generatorProfile: t < 0.33 ? 'easy' : (t < 0.66 ? 'mid' : 'hard'),
);
final probeRuns = runs ~/ 2;
final movesToWin = <int>[];
for (var a = 0; a < probeRuns; a++) {
final run = bot.play(probe, attempt: a);
if (run.won) movesToWin.add(run.movesUsed);
}
// Layout must be reliably solvable given unlimited moves.
if (movesToWin.length < probeRuns * 0.85) return null;
movesToWin.sort();
final median = movesToWin[movesToWin.length ~/ 2];
final margin = 0.45 - 0.25 * t;
var moveLimit = (median * (1 + margin)).ceil().clamp(6, 60);
// Validate at the real budget; nudge the budget up/down until the win
// rate lands inside the band (or we run out of patience).
final band = _band(t);
var spare = <int>[];
var winRate = 0.0;
var accepted = false;
for (var adjust = 0; adjust < 8 && !accepted; adjust++) {
spare = <int>[];
var wins = 0;
final real = StageConfig(
id: probe.id,
seed: stageSeed,
moveLimit: moveLimit,
preset: preset,
objectives: [objective],
stars: probe.stars,
generatorProfile: probe.generatorProfile,
);
for (var a = 0; a < runs; a++) {
final run = bot.play(real, attempt: 1000 + a);
if (run.won) {
wins++;
spare.add(run.movesLeft);
}
}
winRate = wins / runs;
if (winRate > band.$2 && moveLimit > 6) {
moveLimit -= (moveLimit * 0.1).ceil().clamp(1, 4);
} else if (winRate < band.$1 && moveLimit < 60) {
moveLimit += (moveLimit * 0.1).ceil().clamp(1, 4);
} else {
accepted = true;
}
}
if (!accepted || spare.isEmpty) return null;
spare.sort();
var three = spare[(spare.length * 3) ~/ 4];
var two = spare[(spare.length * 2) ~/ 5];
if (three < 2) three = 2;
if (two < 1) two = 1;
if (two >= three) two = three - 1;
if (two < 1) {
two = 1;
three = 2;
}
final stage = StageConfig(
id: probe.id,
seed: stageSeed,
moveLimit: moveLimit,
preset: preset,
objectives: [objective],
stars: StarThresholds(twoMovesLeft: two, threeMovesLeft: three),
generatorProfile: probe.generatorProfile,
);
return (stage, winRate, spare);
}
List<PresetCell> _samplePreset(double t, SeededRng rng) {
final cells = <PresetCell>[];
final taken = <int>{};
void scatter(CellType type, int count, {int colorPool = 8}) {
var placed = 0;
var guard = 0;
while (placed < count && guard < 200) {
guard++;
// Keep presets off the border row/col half the time for nicer shapes.
final x = rng.nextInt(GridState.size);
final y = rng.nextInt(GridState.size);
final key = y * GridState.size + x;
if (taken.contains(key)) continue;
// Never pre-fill an entire line; cap any row/col at 5 preset cells.
final rowCount =
cells.where((c) => c.y == y).length;
final colCount = cells.where((c) => c.x == x).length;
if (rowCount >= 5 || colCount >= 5) continue;
taken.add(key);
cells.add(PresetCell(
x: x,
y: y,
type: type,
colorId: type == CellType.filled ? rng.nextInt(colorPool) : 0,
));
placed++;
}
}
final gems = (1 + (t * 6)).round().clamp(1, 7) + rng.nextInt(2);
scatter(CellType.gem, gems.clamp(1, 8));
final filler = (t * 7).round() + rng.nextInt(3);
scatter(CellType.filled, filler);
return cells;
}
Objective _sampleObjective(
int index, double t, List<PresetCell> preset, SeededRng rng) {
final gemCount =
preset.where((c) => c.type == CellType.gem).length;
// Variety beats: every 5th stage is a score chase, every 7th a line sprint.
if (index % 7 == 6) {
return Objective.clearLines(3 + (t * 7).round() + rng.nextInt(2));
}
if (index % 5 == 4) {
return Objective.reachScore(600 + (t * 2400).round() + rng.nextInt(200));
}
return Objective.clearGems(gemCount);
}
String _objLabel(StageConfig stage) {
final json = stage.objectives.single.toJson();
return '${json['type']} ${json['count'] ?? json['target']}';
}
String _pct(double v) => '${(v * 100).round()}%';