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:
@@ -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()}%';
|
||||
Reference in New Issue
Block a user