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,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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user