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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
# season_001 difficulty report
60 stages, 80 bot runs each, generated in 10s.
| stage | objective | moves | bot win rate | 2★/3★ movesLeft |
|---|---|---|---|---|
| season_001_001 | clearGems 2 | 11 | 89% | 4/5 |
| season_001_002 | clearGems 1 | 7 | 79% | 3/4 |
| season_001_003 | clearGems 1 | 6 | 90% | 2/3 |
| season_001_004 | clearGems 2 | 13 | 84% | 5/6 |
| season_001_005 | reachScore 888 | 28 | 99% | 9/12 |
| season_001_006 | clearGems 3 | 11 | 79% | 4/5 |
| season_001_007 | clearLines 4 | 19 | 100% | 6/8 |
| season_001_008 | clearGems 2 | 6 | 75% | 2/3 |
| season_001_009 | clearGems 2 | 8 | 75% | 3/4 |
| season_001_010 | reachScore 1017 | 27 | 99% | 8/10 |
| season_001_011 | clearGems 2 | 7 | 79% | 2/3 |
| season_001_012 | clearGems 3 | 22 | 79% | 9/12 |
| season_001_013 | clearGems 2 | 12 | 79% | 2/5 |
| season_001_014 | clearLines 5 | 24 | 100% | 7/9 |
| season_001_015 | reachScore 1243 | 30 | 100% | 8/11 |
| season_001_016 | clearGems 4 | 23 | 73% | 7/10 |
| season_001_017 | clearGems 3 | 18 | 73% | 10/11 |
| season_001_018 | clearGems 4 | 14 | 83% | 4/7 |
| season_001_019 | clearGems 3 | 16 | 78% | 5/6 |
| season_001_020 | reachScore 1478 | 32 | 99% | 7/11 |
| season_001_021 | clearLines 5 | 22 | 100% | 6/7 |
| season_001_022 | clearGems 4 | 26 | 86% | 10/13 |
| season_001_023 | clearGems 3 | 10 | 70% | 3/4 |
| season_001_024 | clearGems 3 | 18 | 80% | 5/8 |
| season_001_025 | reachScore 1707 | 28 | 85% | 3/6 |
| season_001_026 | clearGems 5 | 19 | 76% | 3/7 |
| season_001_027 | clearGems 5 | 17 | 86% | 4/8 |
| season_001_028 | clearLines 6 | 20 | 95% | 3/4 |
| season_001_029 | clearGems 4 | 23 | 88% | 7/10 |
| season_001_030 | reachScore 1838 | 28 | 86% | 3/6 |
| season_001_031 | clearGems 5 | 28 | 81% | 8/12 |
| season_001_032 | clearGems 5 | 23 | 74% | 5/9 |
| season_001_033 | clearGems 4 | 24 | 73% | 11/14 |
| season_001_034 | clearGems 4 | 21 | 74% | 5/8 |
| season_001_035 | clearLines 8 | 24 | 88% | 2/4 |
| season_001_036 | clearGems 6 | 25 | 65% | 5/8 |
| season_001_037 | clearGems 6 | 17 | 86% | 6/9 |
| season_001_038 | clearGems 6 | 29 | 78% | 10/15 |
| season_001_039 | clearGems 6 | 29 | 73% | 6/12 |
| season_001_040 | reachScore 2328 | 32 | 80% | 2/6 |
| season_001_041 | clearGems 5 | 17 | 73% | 5/8 |
| season_001_042 | clearLines 9 | 25 | 78% | 1/4 |
| season_001_043 | clearGems 6 | 22 | 79% | 5/9 |
| season_001_044 | clearGems 6 | 26 | 75% | 6/10 |
| season_001_045 | reachScore 2451 | 34 | 88% | 4/6 |
| season_001_046 | clearGems 6 | 22 | 74% | 6/9 |
| season_001_047 | clearGems 7 | 21 | 79% | 5/8 |
| season_001_048 | clearGems 7 | 26 | 71% | 6/11 |
| season_001_049 | clearLines 9 | 24 | 68% | 1/2 |
| season_001_050 | reachScore 2726 | 37 | 93% | 4/8 |
| season_001_051 | clearGems 6 | 24 | 78% | 5/10 |
| season_001_052 | clearGems 6 | 21 | 65% | 5/8 |
| season_001_053 | clearGems 6 | 28 | 83% | 9/14 |
| season_001_054 | clearGems 7 | 21 | 78% | 6/8 |
| season_001_055 | reachScore 2978 | 39 | 91% | 5/8 |
| season_001_056 | clearLines 11 | 30 | 83% | 2/4 |
| season_001_057 | clearGems 7 | 16 | 74% | 5/7 |
| season_001_058 | clearGems 8 | 20 | 85% | 7/10 |
| season_001_059 | clearGems 8 | 23 | 59% | 6/10 |
| season_001_060 | reachScore 3145 | 37 | 60% | 1/5 |
+10
View File
@@ -0,0 +1,10 @@
{
"seasonId": "season_001",
"version": 1,
"title": { "en": "First Bloom", "ko": "첫 개화" },
"theme": { "tileSet": "spring", "background": "background.webp" },
"stageCount": 60,
"baseSeed": 20260611,
"runsPerStage": 80,
"copyToAssets": "assets/seasons/season_001/pack.json"
}
-1
View File
@@ -3,5 +3,4 @@ output-dir: lib/l10n/gen
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
synthetic-package: false
nullable-getter: true
+107
View File
@@ -0,0 +1,107 @@
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;
}
}
+71
View File
@@ -0,0 +1,71 @@
import 'stage.dart';
class SeasonTheme {
const SeasonTheme({required this.tileSet, required this.background});
factory SeasonTheme.fromJson(Map<String, dynamic> json) => SeasonTheme(
tileSet: json['tileSet'] as String,
background: json['background'] as String,
);
final String tileSet;
final String background;
Map<String, dynamic> toJson() =>
{'tileSet': tileSet, 'background': background};
}
/// A season's full content: metadata, theme, and its stages. The unit of
/// remote (or bundled) delivery.
class SeasonPack {
const SeasonPack({
required this.schemaVersion,
required this.seasonId,
required this.version,
required this.title,
required this.theme,
required this.stages,
});
/// Bump when the pack format changes incompatibly; older app builds skip
/// packs with a newer schema instead of crashing.
static const int supportedSchema = 1;
factory SeasonPack.fromJson(Map<String, dynamic> json) {
final schema = json['schemaVersion'] as int;
if (schema > supportedSchema) {
throw FormatException('Unsupported pack schema: $schema');
}
return SeasonPack(
schemaVersion: schema,
seasonId: json['seasonId'] as String,
version: json['version'] as int,
title: (json['title'] as Map<String, dynamic>)
.map((k, v) => MapEntry(k, v as String)),
theme: SeasonTheme.fromJson(json['theme'] as Map<String, dynamic>),
stages: [
for (final stage in json['stages'] as List)
StageConfig.fromJson(stage as Map<String, dynamic>),
],
);
}
final int schemaVersion;
final String seasonId;
final int version;
final Map<String, String> title;
final SeasonTheme theme;
final List<StageConfig> stages;
String titleFor(String languageCode) =>
title[languageCode] ?? title['en'] ?? seasonId;
Map<String, dynamic> toJson() => {
'schemaVersion': schemaVersion,
'seasonId': seasonId,
'version': version,
'title': title,
'theme': theme.toJson(),
'stages': [for (final stage in stages) stage.toJson()],
};
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:block_seasons/game/engine/auto_player.dart';
import 'package:block_seasons/game/models/stage.dart';
import 'package:flutter_test/flutter_test.dart';
StageConfig _easyClearLines() => StageConfig.fromJson({
'id': 'bot_easy',
'seed': 4242,
'moveLimit': 30,
'preset': const <Map<String, dynamic>>[],
'objectives': [
{'type': 'clearLines', 'count': 1},
],
'stars': {
'two': {'movesLeft': 10},
'three': {'movesLeft': 20},
},
'generatorProfile': 'mid',
});
StageConfig _gemStage() => StageConfig.fromJson({
'id': 'bot_gems',
'seed': 555,
'moveLimit': 35,
'preset': [
{'x': 1, 'y': 1, 't': 'gem'},
{'x': 6, 'y': 6, 't': 'gem'},
],
'objectives': [
{'type': 'clearGems', 'count': 2},
],
'stars': {
'two': {'movesLeft': 8},
'three': {'movesLeft': 16},
},
'generatorProfile': 'mid',
});
void main() {
test('bot terminates and reports a result on every attempt', () {
for (var attempt = 0; attempt < 10; attempt++) {
final run = AutoPlayer().play(_easyClearLines(), attempt: attempt);
expect(run.movesUsed, greaterThan(0));
expect(run.movesUsed, lessThanOrEqualTo(35));
if (run.won) {
expect(run.movesLeft, greaterThanOrEqualTo(0));
expect(run.stars, inInclusiveRange(1, 3));
}
}
});
test('bot wins a trivially easy clear-lines stage most of the time', () {
var wins = 0;
for (var attempt = 0; attempt < 20; attempt++) {
if (AutoPlayer().play(_easyClearLines(), attempt: attempt).won) wins++;
}
expect(wins, greaterThanOrEqualTo(16), reason: 'bot too weak: $wins/20');
});
test('bot pursues gem objectives without dead-ending the board', () {
var wins = 0;
for (var attempt = 0; attempt < 20; attempt++) {
if (AutoPlayer().play(_gemStage(), attempt: attempt).won) wins++;
}
expect(wins, greaterThanOrEqualTo(17), reason: 'gem play too weak: $wins/20');
});
test('same attempt number reproduces the same run', () {
final a = AutoPlayer().play(_gemStage(), attempt: 3);
final b = AutoPlayer().play(_gemStage(), attempt: 3);
expect(a.won, b.won);
expect(a.movesUsed, b.movesUsed);
expect(a.score, b.score);
});
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:block_seasons/game/models/season.dart';
import 'package:flutter_test/flutter_test.dart';
const packJson = {
'schemaVersion': 1,
'seasonId': 'season_001',
'version': 2,
'title': {'en': 'First Bloom', 'ko': '첫 개화'},
'theme': {'tileSet': 'spring', 'background': 'background.webp'},
'stages': [
{
'id': 's001_001',
'seed': 101,
'moveLimit': 18,
'preset': [
{'x': 3, 'y': 3, 't': 'gem'},
],
'objectives': [
{'type': 'clearGems', 'count': 1},
],
'stars': {
'two': {'movesLeft': 4},
'three': {'movesLeft': 8},
},
'generatorProfile': 'easy',
},
],
};
void main() {
group('SeasonPack', () {
test('parses the pack schema', () {
final pack = SeasonPack.fromJson(packJson);
expect(pack.schemaVersion, 1);
expect(pack.seasonId, 'season_001');
expect(pack.version, 2);
expect(pack.theme.tileSet, 'spring');
expect(pack.stages, hasLength(1));
expect(pack.stages.first.id, 's001_001');
});
test('round-trips to JSON', () {
final pack = SeasonPack.fromJson(packJson);
expect(pack.toJson(), packJson);
});
test('localized title falls back to English', () {
final pack = SeasonPack.fromJson(packJson);
expect(pack.titleFor('ko'), '첫 개화');
expect(pack.titleFor('en'), 'First Bloom');
expect(pack.titleFor('ja'), 'First Bloom');
});
test('rejects unsupported schema versions', () {
expect(
() => SeasonPack.fromJson({...packJson, 'schemaVersion': 99}),
throwsFormatException,
);
});
});
}
+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()}%';