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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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