Завершить контентную архитектуру этапа 4

This commit is contained in:
2026-08-18 22:03:38 +03:00
parent 2ddac2775f
commit 5051f316fe
39 changed files with 1954 additions and 259 deletions
+4
View File
@@ -25,6 +25,10 @@
{"_type":"issue","id":"goon-game-e03","title":"EPIC-03 Physics","description":"Предсказуемая гибридная физика и бюджеты активных объектов. Разделы 15, 41–43, 65.","acceptance_criteria":"Одинаковая последовательность ввода воспроизводимо приводит к одинаковому состоянию головоломки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:38:49Z","labels":["epic","physics"],"dependencies":[{"issue_id":"goon-game-e03","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e02","title":"EPIC-02 Death \u0026 Corpse","description":"Смерть как игровая механика, состояния тела, CorpseManager и respawn. Разделы 4–8.","acceptance_criteria":"Все типы смерти создают предсказуемое и пригодное для головоломки состояние тела.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:38:47Z","labels":["corpse","death","epic"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-plan","title":"Project Sacrifice: выполнить утверждённый production plan","description":"Главный bead для плана из docs/DEVELOPMENT_PLAN.md: vertical slice, production, alpha, beta и release candidate.","acceptance_criteria":"Release Candidate удовлетворяет критериям раздела 59; все дочерние эпики завершены либо явно исключены решением владельца продукта.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:23Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:44:08Z","started_at":"2026-08-17T21:41:28Z","labels":["gdd","master","production","tdd"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-stage4-bot-traces","title":"Сохранять воспроизводимый журнал команд бота","description":"Добавить versioned trace contract для production-input сценариев с level/format/physics versions, simulation timestamps, inputs и ключевыми checkpoints.","design":"Trace recorder наблюдает публичный Driver/Input контракт и не управляет GameView напрямую; replay engine вне Stage 4 scope, журнал должен быть пригоден для последующего replay.","acceptance_criteria":"Каждый bot runner может выдать deterministic JSON trace; повторная сериализация идентична; timeout diagnostics указывает trace; sensitive/device-local data не записываются.","spec_id":"docs/CURRENT_ROADMAP.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T17:54:29Z","labels":["bot","diagnostics","replay","stage4"],"dependencies":[{"issue_id":"goon-game-stage4-bot-traces","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:32Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-stage4-validator-tools","title":"Добавить build validator и desktop preview/export","description":"Создать запускаемый на desktop инструмент, который валидирует каталог до Android-сборки и экспортирует координаты в читаемый SVG/текстовый отчёт.","design":"Инструмент использует ту же pure-Java загрузку и validator, что runtime, без дублирования правил в Gradle или Python.","acceptance_criteria":"Gradle build отклоняет broken fixture до упаковки APK; CLI валидирует все levels и создаёт deterministic preview/export; команда и формат задокументированы.","spec_id":"docs/CURRENT_ROADMAP.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:28Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T17:54:28Z","labels":["stage4","tooling","validator"],"dependencies":[{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:31Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-level-json","type":"blocks","created_at":"2026-08-18T20:54:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-stage4-level-json","title":"Мигрировать LevelCatalog в versioned JSON","description":"Добавить формат уровня, loader и чистый доменный validator, затем перенести все 10 LevelDefinition из Java-констант в assets JSON вместе с RU/EN title и hint.","design":"Tracer bullet: один JSON проходит codec+validator через публичный catalog loader; затем миграция уровня за уровнем с эквивалентностными контрактами.","acceptance_criteria":"Формат и physics version проверяются; десять JSON загружаются в тот же наблюдаемый каталог; spawn, goal, bounds, уникальность номеров и ссылочная целостность проверены public behavior tests; LevelCatalog больше не содержит геометрию уровней.","spec_id":"docs/CURRENT_ROADMAP.md","status":"in_progress","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T17:54:35Z","started_at":"2026-08-18T17:54:35Z","labels":["json","levels","stage4"],"dependencies":[{"issue_id":"goon-game-stage4-level-json","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:31Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-stage4-content-architecture","title":"Stage 4: контентная архитектура и инструменты","description":"Перевести десять уровней в versioned data-driven формат, добавить build-time validation, desktop preview/export и воспроизводимые bot traces без изменения игровой физики.","design":"Один канонический JSON на уровень; Android loader и build tool применяют одинаковые инварианты; runtime не имеет Java-констант геометрии; изменения выполняются вертикальными RED-GREEN срезами.","acceptance_criteria":"Уровни загружаются из JSON; невалидные геометрия, ссылки и обязательные объекты отклоняются при сборке; desktop tool выдаёт проверяемый preview/export; format/physics versions зафиксированы; все существующие боты остаются GREEN.","spec_id":"docs/CURRENT_ROADMAP.md","status":"in_progress","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:26Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T17:54:34Z","started_at":"2026-08-18T17:54:34Z","labels":["content","levels","stage4","tooling"],"dependencies":[{"issue_id":"goon-game-stage4-content-architecture","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T20:54:30Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-stage3-localization","title":"Persistent RU/EN локализация","description":"Локализовать player-facing Canvas UI, названия и hints уровней на русский и английский с переключателем языка.","acceptance_criteria":"Все player-facing строки имеют RU/EN варианты; выбранный язык сохраняется после Activity recreation; смена языка не сбрасывает progress или control settings.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T13:09:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T16:26:42Z","started_at":"2026-08-18T13:09:56Z","closed_at":"2026-08-18T16:26:42Z","close_reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10.","labels":["localization","stage3","ui"],"dependencies":[{"issue_id":"goon-game-stage3-localization","depends_on_id":"goon-game-menus-localization","type":"parent-child","created_at":"2026-08-18T16:09:56Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-stage3-onboarding-results","title":"Onboarding и полноценный result flow","description":"Добавить короткое пропускаемое обучение без блокирующих длинных окон и result screen с текущими/лучшими временем и жертвами, replay/next/levels.","acceptance_criteria":"Первый запуск объясняет движение, прыжок и полезную смерть; обучение можно пропустить и оно не повторяется после завершения; result показывает current/best и все переходы работают без сброса прогресса.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T13:09:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T16:26:41Z","started_at":"2026-08-18T13:09:55Z","closed_at":"2026-08-18T16:26:41Z","close_reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10.","labels":["onboarding","stage3","ui"],"dependencies":[{"issue_id":"goon-game-stage3-onboarding-results","depends_on_id":"goon-game-menus-localization","type":"parent-child","created_at":"2026-08-18T16:09:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-level-route-risks","title":"Доказать спорные маршруты уровней 2, 4 и 10","description":"Проверить возможный sequence break уровня 2, узкий lift→gallery прыжок уровня 4 и optional FREEZER/body delivery/lift→ledge риски уровня 10.","acceptance_criteria":"Для каждого риска есть воспроизводимый connected bot test; intended route проходит 3 раза, а допустимые альтернативы явно зафиксированы или геометрия исправлена.","notes":"2026-08-18: exploratory production-input direct-jump attempt on level 2 did not confirm the suspected zero-death sequence break; it timed out at x=549.36 with deaths=2/corpses=2. Intended three-corpse route separately passes 3 times. Sequence-break risk remains unproven and open.\n2026-08-18: L4 lift route remains green 3x. L10 bot now explicitly stands on and jumps from the frozen corpse, then completes normal 3x plus delayed traversal. L2 intended three-corpse route remains green 3x; the unconfirmed zero-death hypothesis is accepted as a non-blocking emergent alternative because targetDeaths measures the intended route rather than enforced uniqueness.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T10:41:48Z","closed_at":"2026-08-18T10:41:48Z","close_reason":"All intended risky routes are reproduced by connected bots; possible L2 alternative is explicitly accepted and documented as non-blocking.","labels":["balance","bot","current","levels"],"dependencies":[{"issue_id":"goon-game-level-route-risks","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:10:06Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
+21
View File
@@ -7,6 +7,7 @@ Project documents:
- [`docs/DEVELOPMENT_PLAN.md`](docs/DEVELOPMENT_PLAN.md) — approved GDD + TDD + production roadmap;
- [`docs/CURRENT_ROADMAP.md`](docs/CURRENT_ROADMAP.md) — execution roadmap for the existing vertical slice;
- [`docs/LEVEL_SOLUTION_GRAPHS.md`](docs/LEVEL_SOLUTION_GRAPHS.md) — source-backed intended solution graph for every current level;
- [`docs/STAGE4_CONTENT_FORMAT.md`](docs/STAGE4_CONTENT_FORMAT.md) — versioned JSON contract, validator, preview, and bot-trace format;
- [`docs/BEADS_WORKFLOW.md`](docs/BEADS_WORKFLOW.md) — local backlog structure and daily Beads commands.
## Playable content
@@ -51,6 +52,20 @@ Requirements: JDK 17 and Android SDK Platform/Build Tools 34.
./gradlew testDebugUnitTest assembleDebug
```
Level content is stored in `level-format/src/main/resources/levels`. Android and
the desktop tools use the same dependency-free parser and validator. Android
packaging automatically depends on validation. To validate explicitly or export
deterministic SVG/TSV previews:
```sh
./gradlew :level-format:validateLevels
./gradlew :level-format:previewLevels
```
Previews are written to `level-format/build/level-previews`. Invalid geometry,
versions, moving-platform destinations, switch parameters, or signal references
fail the build with a JSON path diagnostic.
All ten levels have device-level acceptance bots. They drive the production
`GameView` through the same movement and jump inputs as the touch controls and
run each intended solution three times. Level 8 also has a dedicated recovery
@@ -69,6 +84,12 @@ The Stage 3 device suite additionally checks persisted controls, RU/EN copy,
accessibility presets, tutorial/result navigation, Back behavior, and safe viewports
on 20:9, 16:9, and 16:10 tablet-sized displays.
Stage 4 keeps the same gameplay and UI matrix after migrating all ten levels to
JSON. Its final unfiltered Android 16 run passed all 30 instrumentation methods
in one process (`OK (30 tests)`, 1,096.448 seconds). Acceptance bots now attach a
versioned deterministic input/checkpoint trace and SHA-256 trace ID to their
result diagnostics.
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with:
```sh
+7 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId 'com.projectsacrifice.game'
minSdk 23
targetSdk 34
versionCode 7
versionName '0.6.0-stage3-complete'
versionCode 8
versionName '0.7.0-stage4-content'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
@@ -38,8 +38,13 @@ android {
}
dependencies {
implementation project(':level-format')
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test:rules:1.5.0'
}
tasks.named('preBuild').configure {
dependsOn ':level-format:validateLevels'
}
@@ -26,6 +26,7 @@ public final class LevelEightPlaythroughTest {
.recoverAfterClockExpires(driver, 20_000L);
assertTrue("Recovery run " + run + ": " + result.diagnostic(), result.completed());
assertEquals("Recovery must not hide a reset/death shortcut", 0, result.deaths());
assertTrace(result);
}
}
}
@@ -39,6 +40,7 @@ public final class LevelEightPlaythroughTest {
.completeIntendedRoute(driver, 15_000L);
assertTrue("Intended run " + run + ": " + result.diagnostic(), result.completed());
assertEquals("The intended route is deathless", 0, result.deaths());
assertTrace(result);
}
}
}
@@ -51,9 +53,16 @@ public final class LevelEightPlaythroughTest {
assertTrue("Stressed scheduling: " + result.diagnostic(), result.completed());
assertEquals("The stressed intended route is deathless", 0, result.deaths());
assertTrace(result);
}
}
private static void assertTrace(LevelEightScenario.Result result) {
assertTrue(result.traceJson(), result.traceJson().contains("\"traceVersion\":1"));
assertTrue(result.traceJson(), result.traceJson().contains("\"level\":8"));
assertTrue(result.diagnostic(), result.diagnostic().contains("traceId=" + result.traceId()));
}
private static AndroidDriver driver(ActivityScenario<MainActivity> activity) {
return driver(activity, 0L);
}
@@ -11,19 +11,26 @@ final class LevelEightScenario {
private final boolean completed;
private final int deaths;
private final String diagnostic;
private final String traceJson;
private final String traceId;
Result(boolean completed, int deaths, String diagnostic) {
Result(boolean completed, int deaths, String diagnostic, BotCommandTrace trace) {
this.completed = completed;
this.deaths = deaths;
this.diagnostic = diagnostic;
this.traceJson = trace.json();
this.traceId = trace.id();
this.diagnostic = diagnostic + "; traceId=" + traceId;
}
boolean completed() { return completed; }
int deaths() { return deaths; }
String diagnostic() { return diagnostic; }
String traceJson() { return traceJson; }
String traceId() { return traceId; }
}
Result recoverAfterClockExpires(LevelPlaytestBot.Driver driver, long timeoutMillis) {
driver = new BotTraceDriver(driver, "Level 8 recovery route");
driver.startLevel(8);
GameView.AutomationSnapshot snapshot = driver.snapshot();
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(
@@ -71,6 +78,7 @@ final class LevelEightScenario {
}
Result completeIntendedRoute(LevelPlaytestBot.Driver driver, long timeoutMillis) {
driver = new BotTraceDriver(driver, "Level 8 intended route");
driver.startLevel(8);
GameView.AutomationSnapshot snapshot = driver.snapshot();
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(
@@ -99,6 +107,7 @@ final class LevelEightScenario {
&& MovingPlatformLaunchPolicy.readyToBoard(current, 1_050f));
if (failed(snapshot, budget, driver)) return snapshot;
traceStep(driver, "Jump onto the moving platform");
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
@@ -112,6 +121,7 @@ final class LevelEightScenario {
current -> MovingPlatformLaunchPolicy.ready(current, LAUNCH_PLATFORM_X));
if (failed(snapshot, budget, driver)) return snapshot;
traceStep(driver, "Jump from the moving platform");
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
@@ -148,6 +158,7 @@ final class LevelEightScenario {
return failure("Reach the open-door jump", snapshot, budget, driver);
}
traceStep(driver, "Final ledge jump");
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
@@ -155,15 +166,17 @@ final class LevelEightScenario {
GameView.AutomationSnapshot::completed);
driver.setInput(false, false, false);
if (!snapshot.completed()) return failure(step, snapshot, budget, driver);
BotCommandTrace trace = finishTrace(driver, "COMPLETED", step, snapshot);
return new Result(true, snapshot.deaths(), step + " completed; "
+ budget.timingState(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
+ "; " + state(snapshot));
+ "; " + state(snapshot), trace);
}
private GameView.AutomationSnapshot positionForFinalJump(
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
) {
lastStep = "Reach the open-door jump";
traceStep(driver, lastStep);
GameView.AutomationSnapshot snapshot = driver.snapshot();
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
&& snapshot.playerAlive() && !snapshot.completed() && snapshot.allDoorsOpen()) {
@@ -188,6 +201,7 @@ final class LevelEightScenario {
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step,
LevelPlaytestBot.SnapshotCondition completion
) {
traceStep(driver, step);
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
@@ -198,6 +212,7 @@ final class LevelEightScenario {
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
) {
lastStep = "Return beneath the recovery relay";
traceStep(driver, lastStep);
GameView.AutomationSnapshot snapshot = driver.snapshot();
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
&& snapshot.playerAlive() && !snapshot.completed()) {
@@ -223,6 +238,7 @@ final class LevelEightScenario {
LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion
) {
lastStep = step;
traceStep(driver, step);
GameView.AutomationSnapshot snapshot = driver.snapshot();
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
&& snapshot.playerAlive()
@@ -260,10 +276,24 @@ final class LevelEightScenario {
private Result failure(String step, GameView.AutomationSnapshot snapshot,
PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) {
BotCommandTrace trace = finishTrace(driver, "FAILED", step, snapshot);
return new Result(false, snapshot.deaths(), step + " failed; lastStep=" + lastStep
+ "; " + budget.timingState(
snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
+ "; " + state(snapshot));
+ "; " + state(snapshot), trace);
}
private static void traceStep(LevelPlaytestBot.Driver driver, String step) {
if (driver instanceof BotTraceDriver) ((BotTraceDriver) driver).step(step);
}
private static BotCommandTrace finishTrace(LevelPlaytestBot.Driver driver, String outcome,
String step,
GameView.AutomationSnapshot snapshot) {
if (!(driver instanceof BotTraceDriver)) {
throw new IllegalStateException("Level 8 scenario requires its tracing driver");
}
return ((BotTraceDriver) driver).finish(outcome, step, snapshot);
}
private static String state(GameView.AutomationSnapshot snapshot) {
@@ -0,0 +1,195 @@
package com.projectsacrifice.game;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
/** Immutable, portable journal of public bot inputs and observable checkpoints. */
final class BotCommandTrace {
static final int TRACE_VERSION = 1;
private final String json;
private final String id;
private BotCommandTrace(String json) {
this.json = json;
this.id = sha256(json);
}
String json() { return json; }
String id() { return id; }
static Builder builder(int levelNumber, String scenario,
GameView.AutomationSnapshot initial) {
return new Builder(levelNumber, scenario, initial);
}
static final class Builder {
private final int levelNumber;
private final int levelFormatVersion;
private final int physicsVersion;
private final String scenario;
private final long simulationStartedAt;
private final List<Event> events = new ArrayList<>();
private String lastInputKey;
private String outcome = "RUNNING";
Builder(int levelNumber, String scenario, GameView.AutomationSnapshot initial) {
this.levelNumber = levelNumber;
LevelDefinition level = LevelCatalog.get(levelNumber - 1);
this.levelFormatVersion = level.formatVersion;
this.physicsVersion = level.physicsVersion;
this.scenario = scenario == null ? "" : scenario;
this.simulationStartedAt = initial.elapsedMilliseconds();
events.add(Event.state(0L, "START", "", false, false, false, initial));
}
void input(String step, boolean left, boolean right, boolean jump,
GameView.AutomationSnapshot snapshot) {
String key = safe(step) + '|' + left + '|' + right + '|' + jump;
if (key.equals(lastInputKey)) return;
lastInputKey = key;
events.add(Event.state(elapsed(snapshot), "INPUT", safe(step),
left, right, jump, snapshot));
}
void checkpoint(String step, GameView.AutomationSnapshot snapshot) {
lastInputKey = null;
events.add(Event.state(elapsed(snapshot), "CHECKPOINT", safe(step),
false, false, false, snapshot));
}
BotCommandTrace finish(String outcome, String step,
GameView.AutomationSnapshot snapshot) {
this.outcome = safe(outcome);
events.add(Event.state(elapsed(snapshot), "FINISH", safe(step),
false, false, false, snapshot));
return new BotCommandTrace(json());
}
private long elapsed(GameView.AutomationSnapshot snapshot) {
return Math.max(0L, snapshot.elapsedMilliseconds() - simulationStartedAt);
}
private String json() {
StringBuilder value = new StringBuilder();
value.append('{')
.append("\"traceVersion\":").append(TRACE_VERSION).append(',')
.append("\"level\":").append(levelNumber).append(',')
.append("\"levelFormatVersion\":").append(levelFormatVersion).append(',')
.append("\"physicsVersion\":").append(physicsVersion).append(',')
.append("\"scenario\":\"").append(escape(scenario)).append("\",")
.append("\"outcome\":\"").append(escape(outcome)).append("\",")
.append("\"events\":[");
for (int i = 0; i < events.size(); i++) {
if (i > 0) value.append(',');
events.get(i).appendJson(value);
}
return value.append("]}\n").toString();
}
}
private static final class Event {
final long simulationMs;
final String kind;
final String step;
final boolean left;
final boolean right;
final boolean jump;
final float x;
final float y;
final boolean alive;
final boolean completed;
final int deaths;
final int corpses;
final boolean allDoorsOpen;
private Event(long simulationMs, String kind, String step,
boolean left, boolean right, boolean jump,
GameView.AutomationSnapshot snapshot) {
this.simulationMs = simulationMs;
this.kind = kind;
this.step = step;
this.left = left;
this.right = right;
this.jump = jump;
this.x = snapshot.playerX();
this.y = snapshot.playerY();
this.alive = snapshot.playerAlive();
this.completed = snapshot.completed();
this.deaths = snapshot.deaths();
this.corpses = snapshot.corpseCount();
this.allDoorsOpen = snapshot.allDoorsOpen();
}
static Event state(long simulationMs, String kind, String step,
boolean left, boolean right, boolean jump,
GameView.AutomationSnapshot snapshot) {
return new Event(simulationMs, kind, step, left, right, jump, snapshot);
}
void appendJson(StringBuilder value) {
value.append('{')
.append("\"simulationMs\":").append(simulationMs).append(',')
.append("\"kind\":\"").append(escape(kind)).append("\",")
.append("\"step\":\"").append(escape(step)).append("\",")
.append("\"input\":{")
.append("\"left\":").append(left).append(',')
.append("\"right\":").append(right).append(',')
.append("\"jump\":").append(jump).append("},")
.append("\"state\":{")
.append("\"x\":").append(number(x)).append(',')
.append("\"y\":").append(number(y)).append(',')
.append("\"alive\":").append(alive).append(',')
.append("\"completed\":").append(completed).append(',')
.append("\"deaths\":").append(deaths).append(',')
.append("\"corpses\":").append(corpses).append(',')
.append("\"allDoorsOpen\":").append(allDoorsOpen)
.append("}}");
}
}
private static String safe(String value) {
return value == null ? "" : value;
}
private static String number(float value) {
if (!Float.isFinite(value)) return "null";
if (value == (long) value) return Long.toString((long) value);
return Float.toString(value);
}
private static String escape(String value) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
switch (c) {
case '"': result.append("\\\""); break;
case '\\': result.append("\\\\"); break;
case '\b': result.append("\\b"); break;
case '\f': result.append("\\f"); break;
case '\n': result.append("\\n"); break;
case '\r': result.append("\\r"); break;
case '\t': result.append("\\t"); break;
default:
if (c < 0x20) result.append(String.format("\\u%04x", (int) c));
else result.append(c);
}
}
return result.toString();
}
private static String sha256(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder();
for (byte item : digest) result.append(String.format("%02x", item & 0xff));
return result.toString();
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
}
@@ -0,0 +1,63 @@
package com.projectsacrifice.game;
/** Adds versioned command tracing to bespoke bots without changing their input boundary. */
final class BotTraceDriver implements LevelPlaytestBot.Driver {
private final LevelPlaytestBot.Driver delegate;
private final String scenario;
private BotCommandTrace.Builder trace;
private GameView.AutomationSnapshot latest;
private String step = "Start";
BotTraceDriver(LevelPlaytestBot.Driver delegate, String scenario) {
if (delegate == null) throw new IllegalArgumentException("driver is required");
this.delegate = delegate;
this.scenario = scenario == null ? "" : scenario;
}
void step(String value) {
step = value == null ? "" : value;
}
BotCommandTrace finish(String outcome, String finalStep,
GameView.AutomationSnapshot snapshot) {
ensureStarted();
latest = snapshot == null ? latest : snapshot;
return trace.finish(outcome, finalStep, latest);
}
@Override
public void startLevel(int oneBasedLevelNumber) {
delegate.startLevel(oneBasedLevelNumber);
latest = delegate.snapshot();
trace = BotCommandTrace.builder(oneBasedLevelNumber, scenario, latest);
}
@Override
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
ensureStarted();
delegate.setInput(moveLeft, moveRight, jump);
trace.input(step, moveLeft, moveRight, jump, latest);
}
@Override
public GameView.AutomationSnapshot snapshot() {
latest = delegate.snapshot();
return latest;
}
@Override
public void waitForNextDecision(long millis) {
delegate.waitForNextDecision(millis);
}
@Override
public long currentTimeMillis() {
return delegate.currentTimeMillis();
}
private void ensureStarted() {
if (trace == null || latest == null) {
throw new IllegalStateException("startLevel must be called before tracing input");
}
}
}
@@ -2,41 +2,6 @@ package com.projectsacrifice.game;
/** All player-facing copy behind one typed, Android-independent interface. */
public final class GameStrings {
private static final String[] EN_TITLES = {
"The Volunteer", "A Useful End", "Dead Weight", "The Long Lift",
"Express Delivery", "Cold Comfort", "Live Wire", "Borrowed Time",
"Two Good Reasons", "The Last Procession"
};
private static final String[] RU_TITLES = {
"Рыцарь-доброволец", "Полезный конец", "Мёртвый груз", "Долгий подъём",
"Срочная доставка", "Холодное утешение", "Живой провод", "Время взаймы",
"Две веские причины", "Последнее шествие"
};
private static final String[] EN_HINTS = {
"Reach the relic. Arrows move; the winged button jumps.",
"The spikes keep every volunteer. Build a path from what remains.",
"Leave a body on the brass plate so the next volunteer can pass.",
"Touch the lever, then ride the moving platform to the upper gallery.",
"The saw throws to the right. Deliver a volunteer to the distant plate.",
"The frost makes a solid block. Use the frozen volunteer as a step.",
"A fallen volunteer conducts the current and keeps the relay alive.",
"Jump for the far blue relay if the timed gate closes.",
"The final gate needs weight and power at the same time.",
"Launch, freeze, conduct, ascend. Every lesson opens the last relic."
};
private static final String[] RU_HINTS = {
"Доберитесь до реликвии. Стрелки двигают; кнопка с крылом — прыжок.",
"Шипы сохраняют каждого добровольца. Проложите путь из того, что осталось.",
"Оставьте тело на латунной плите, чтобы следующий доброволец прошёл.",
"Коснитесь рычага, затем поднимитесь на платформе к верхней галерее.",
"Пила бросает вправо. Доставьте добровольца на дальнюю плиту.",
"Мороз создаёт твёрдый блок. Используйте замёрзшего добровольца как ступень.",
"Павший доброволец проводит ток и поддерживает реле.",
"Прыгните к дальнему синему реле, если ворота по таймеру закроются.",
"Последним воротам одновременно нужны вес и питание.",
"Разгонитесь, замёрзните, проведите ток, поднимитесь. Каждый урок открывает последнюю реликвию."
};
private final GameLanguage language;
private GameStrings(GameLanguage language) {
@@ -308,11 +273,13 @@ public final class GameStrings {
}
public String levelTitle(int oneBasedLevelNumber) {
return (russian() ? RU_TITLES : EN_TITLES)[levelIndex(oneBasedLevelNumber)];
LevelDefinition level = level(oneBasedLevelNumber);
return russian() ? level.titleRu : level.title;
}
public String levelHint(int oneBasedLevelNumber) {
return (russian() ? RU_HINTS : EN_HINTS)[levelIndex(oneBasedLevelNumber)];
LevelDefinition level = level(oneBasedLevelNumber);
return russian() ? level.hintRu : level.hint;
}
private String volunteerWord(int count) {
@@ -329,8 +296,9 @@ public final class GameStrings {
return index >= 0 && index <= 2 ? index : 1;
}
private static int levelIndex(int oneBasedLevelNumber) {
private static LevelDefinition level(int oneBasedLevelNumber) {
int index = oneBasedLevelNumber - 1;
return index >= 0 && index < EN_TITLES.length ? index : 0;
if (index < 0 || index >= LevelCatalog.all().size()) index = 0;
return LevelCatalog.get(index);
}
}
@@ -1,197 +0,0 @@
package com.projectsacrifice.game;
import static com.projectsacrifice.game.LevelDefinition.HazardType.ELECTRIC;
import static com.projectsacrifice.game.LevelDefinition.HazardType.FREEZER;
import static com.projectsacrifice.game.LevelDefinition.HazardType.PIT;
import static com.projectsacrifice.game.LevelDefinition.HazardType.SAW;
import static com.projectsacrifice.game.LevelDefinition.HazardType.SPIKES;
import static com.projectsacrifice.game.LevelDefinition.SwitchType.PRESSURE;
import static com.projectsacrifice.game.LevelDefinition.SwitchType.TIMED;
import static com.projectsacrifice.game.LevelDefinition.SwitchType.TOGGLE;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/** Ten original vertical-slice puzzles with a one-mechanic-at-a-time learning curve. */
public final class LevelCatalog {
private static final float FLOOR_Y = 880f;
private static final List<LevelDefinition> LEVELS = Collections.unmodifiableList(Arrays.asList(
level1(), level2(), level3(), level4(), level5(),
level6(), level7(), level8(), level9(), level10()
));
private LevelCatalog() {}
public static List<LevelDefinition> all() { return LEVELS; }
public static LevelDefinition get(int index) {
if (index < 0 || index >= LEVELS.size()) {
throw new IllegalArgumentException("Unknown level index: " + index);
}
return LEVELS.get(index);
}
private static LevelDefinition level1() {
return level(
1, "The Volunteer", "Reach the relic. Arrows move; the winged button jumps.",
1920, 130, 760, goal(1690, 770), 0, 30,
platforms(floor(0, 1920), ledge(640, 760, 230), ledge(1060, 690, 250)),
hazards(), switches(), doors()
);
}
private static LevelDefinition level2() {
return level(
2, "A Useful End", "The spikes keep every volunteer. Build a path from what remains.",
2050, 130, 760, goal(1830, 770), 3, 55,
platforms(floor(0, 2050), ledge(1180, 740, 240)),
hazards(hazard(SPIKES, 610, 850, 260, 30)), switches(), doors()
);
}
private static LevelDefinition level3() {
return level(
3, "Dead Weight", "Leave a body on the brass plate so the next volunteer can pass.",
2050, 130, 760, goal(1810, 770), 1, 50,
platforms(floor(0, 2050)),
hazards(hazard(SAW, 565, 760, 92, 92, null, 0, -150)),
switches(sw(PRESSURE, 540, 860, 150, 20, "gate", 0, 1)),
doors(door(1110, 570, 78, 310, "gate"))
);
}
private static LevelDefinition level4() {
return level(
4, "The Long Lift", "Touch the lever, then ride the moving platform to the upper gallery.",
2200, 130, 760, goal(1960, 390), 0, 60,
platforms(
floor(0, 1500), floor(1820, 380), ledge(1660, 500, 540),
moving(1040, 790, 260, 34, 0, -390, 5.2f, "lift")
),
hazards(hazard(PIT, 1500, 880, 320, 200)),
switches(sw(TOGGLE, 520, 820, 72, 60, "lift", 0, 1)), doors()
);
}
private static LevelDefinition level5() {
return level(
5, "Express Delivery", "The saw throws to the right. Deliver a volunteer to the distant plate.",
2300, 130, 760, goal(2080, 770), 1, 65,
platforms(floor(0, 2300), ledge(850, 800, 330)),
hazards(hazard(SAW, 570, 760, 96, 96, null, 500, -700)),
switches(sw(PRESSURE, 930, 780, 160, 20, "gate", 0, 1)),
doors(door(1390, 560, 82, 320, "gate"))
);
}
private static LevelDefinition level6() {
return level(
6, "Cold Comfort", "The frost makes a solid block. Use the frozen volunteer as a step.",
2150, 130, 760, goal(1880, 500), 1, 65,
platforms(floor(0, 1120), ledge(810, 590, 520), ledge(1430, 610, 570)),
hazards(hazard(FREEZER, 690, 776, 100, 104)), switches(), doors()
);
}
private static LevelDefinition level7() {
return level(
7, "Live Wire", "A fallen volunteer conducts the current and keeps the relay alive.",
2150, 130, 760, goal(1930, 770), 1, 55,
platforms(floor(0, 2150)),
hazards(hazard(ELECTRIC, 650, 780, 110, 100, "power", 0, 0)),
switches(), doors(door(1130, 560, 84, 320, "power"))
);
}
private static LevelDefinition level8() {
return level(
8, "Borrowed Time", "Jump for the far blue relay if the timed gate closes.",
2450, 130, 760, goal(2220, 600), 0, 38,
platforms(
floor(0, 1040), floor(1510, 940), ledge(1980, 710, 470),
moving(910, 770, 320, 34, 380, 0, 4.0f, "clock")
),
hazards(hazard(PIT, 1040, 880, 470, 200)),
switches(
sw(TIMED, 560, 810, 90, 70, "clock", 8.0f, 1),
sw(TIMED, 1600, 650, 90, 70, "clock", 3.5f, 1)
),
doors(door(1770, 560, 82, 320, "clock"))
);
}
private static LevelDefinition level9() {
return level(
9, "Two Good Reasons", "The final gate needs weight and power at the same time.",
2550, 130, 760, goal(2310, 770), 2, 80,
platforms(floor(0, 2550)),
hazards(
hazard(SPIKES, 520, 850, 130, 30),
hazard(ELECTRIC, 910, 780, 110, 100, "power", 0, 0)
),
switches(sw(PRESSURE, 510, 842, 160, 38, "weight", 0, 1)),
doors(door(1510, 540, 86, 340, "weight", "power"))
);
}
private static LevelDefinition level10() {
return level(
10, "The Last Procession", "Launch, freeze, conduct, ascend. Every lesson opens the last relic.",
3100, 130, 760, goal(2880, 330), 3, 120,
platforms(
floor(0, 1940), floor(2140, 960), ledge(2420, 440, 680),
moving(1800, 790, 290, 34, 0, -410, 10.0f, "lift")
),
hazards(
hazard(SAW, 520, 760, 96, 96, null, 700, -240),
hazard(FREEZER, 1110, 776, 100, 104),
hazard(ELECTRIC, 1510, 780, 110, 100, "power", 0, 0),
hazard(PIT, 1940, 880, 200, 200)
),
switches(sw(PRESSURE, 860, 860, 160, 20, "lift", 0, 1)),
doors(door(2310, 310, 82, 570, "power"))
);
}
private static LevelDefinition level(
int number, String title, String hint, float width,
float spawnX, float spawnY, LevelDefinition.Box goal,
int targetDeaths, int targetSeconds,
List<LevelDefinition.Platform> platforms,
List<LevelDefinition.Hazard> hazards,
List<LevelDefinition.Switch> switches,
List<LevelDefinition.Door> doors
) {
return new LevelDefinition(number, title, hint, width, spawnX, spawnY, goal,
targetDeaths, targetSeconds, platforms, hazards, switches, doors);
}
private static LevelDefinition.Box box(float x, float y, float w, float h) {
return new LevelDefinition.Box(x, y, w, h);
}
private static LevelDefinition.Box goal(float x, float y) { return box(x, y, 86, 110); }
private static LevelDefinition.Platform floor(float x, float w) { return new LevelDefinition.Platform(box(x, FLOOR_Y, w, 200)); }
private static LevelDefinition.Platform ledge(float x, float y, float w) { return new LevelDefinition.Platform(box(x, y, w, 34)); }
private static LevelDefinition.Platform moving(float x, float y, float w, float h, float tx, float ty, float period, String control) {
return new LevelDefinition.Platform(box(x, y, w, h), tx, ty, period, control);
}
private static LevelDefinition.Hazard hazard(LevelDefinition.HazardType type, float x, float y, float w, float h) {
return new LevelDefinition.Hazard(type, box(x, y, w, h));
}
private static LevelDefinition.Hazard hazard(LevelDefinition.HazardType type, float x, float y, float w, float h, String relay, float ix, float iy) {
return new LevelDefinition.Hazard(type, box(x, y, w, h), relay, ix, iy);
}
private static LevelDefinition.Switch sw(LevelDefinition.SwitchType type, float x, float y, float w, float h, String id, float duration, int mass) {
return new LevelDefinition.Switch(type, box(x, y, w, h), id, duration, mass);
}
private static LevelDefinition.Door door(float x, float y, float w, float h, String... ids) {
return new LevelDefinition.Door(box(x, y, w, h), ids);
}
@SafeVarargs private static List<LevelDefinition.Platform> platforms(LevelDefinition.Platform... values) { return Arrays.asList(values); }
@SafeVarargs private static List<LevelDefinition.Hazard> hazards(LevelDefinition.Hazard... values) { return Arrays.asList(values); }
@SafeVarargs private static List<LevelDefinition.Switch> switches(LevelDefinition.Switch... values) { return Arrays.asList(values); }
@SafeVarargs private static List<LevelDefinition.Door> doors(LevelDefinition.Door... values) { return Arrays.asList(values); }
}
@@ -127,14 +127,18 @@ public final class LevelPlaytestBot {
private final int corpseCount;
private final String diagnostic;
private final String lastStep;
private final String traceJson;
private final String traceId;
Result(boolean completed, boolean doorOpened, int deaths, int corpseCount,
String diagnostic, String lastStep) {
String diagnostic, String lastStep, BotCommandTrace trace) {
this.completed = completed;
this.doorOpened = doorOpened;
this.deaths = deaths;
this.corpseCount = corpseCount;
this.diagnostic = diagnostic;
this.traceJson = trace.json();
this.traceId = trace.id();
this.diagnostic = diagnostic + "; traceId=" + traceId;
this.lastStep = lastStep;
}
@@ -144,6 +148,8 @@ public final class LevelPlaytestBot {
public int corpseCount() { return corpseCount; }
public String diagnostic() { return diagnostic; }
public String lastStep() { return lastStep; }
public String traceJson() { return traceJson; }
public String traceId() { return traceId; }
}
public Result run(Driver driver, Scenario scenario, long timeoutMillis) {
@@ -154,6 +160,8 @@ public final class LevelPlaytestBot {
String lastStep = scenario.steps.isEmpty() ? null : scenario.steps.get(0).name;
boolean doorOpened = false;
GameView.AutomationSnapshot last = driver.snapshot();
BotCommandTrace.Builder trace = BotCommandTrace.builder(
scenario.levelNumber, scenario.name, last);
long scenarioStartedAtSimulation = last.elapsedMilliseconds();
long stepStartedAtSimulation = scenarioStartedAtSimulation;
@@ -164,20 +172,22 @@ public final class LevelPlaytestBot {
last.elapsedMilliseconds(), scenarioStartedAtSimulation);
doorOpened |= last.allDoorsOpen();
if (last.completed()) {
driver.setInput(false, false, false);
applyInput(driver, trace, Input.none(), lastStep, last);
BotCommandTrace finishedTrace = trace.finish("COMPLETED", lastStep, last);
return new Result(true, doorOpened, last.deaths(), last.corpseCount(),
scenario.name + " completed; step=" + lastStep + "; "
+ timingState(simulationElapsed, wallElapsed) + "; "
+ snapshotState(last),
lastStep);
lastStep, finishedTrace);
}
if (wallElapsed >= wallWatchdogMillis) {
driver.setInput(false, false, false);
applyInput(driver, trace, Input.none(), lastStep, last);
BotCommandTrace finishedTrace = trace.finish("WALL_WATCHDOG", lastStep, last);
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
scenario.name + " stopped by wall-clock watchdog; step=" + lastStep
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
+ snapshotState(last),
lastStep);
lastStep, finishedTrace);
}
if (simulationElapsed >= timeoutMillis) break;
if (stepIndex >= scenario.steps.size()) break;
@@ -185,34 +195,43 @@ public final class LevelPlaytestBot {
Step step = scenario.steps.get(stepIndex);
lastStep = step.name;
if (step.completion.reached(last)) {
driver.setInput(false, false, false);
trace.checkpoint(step.name, last);
applyInput(driver, trace, Input.none(), step.name, last);
stepIndex++;
stepStartedAtSimulation = last.elapsedMilliseconds();
continue;
}
if (elapsed(last.elapsedMilliseconds(), stepStartedAtSimulation)
>= step.timeoutMillis) {
driver.setInput(false, false, false);
applyInput(driver, trace, Input.none(), step.name, last);
BotCommandTrace finishedTrace = trace.finish("STEP_TIMEOUT", step.name, last);
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
scenario.name + " timed out in simulation; step=" + step.name
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
+ snapshotState(last),
step.name);
step.name, finishedTrace);
}
driver.setInput(step.input.moveLeft, step.input.moveRight, step.input.jump);
applyInput(driver, trace, step.input, step.name, last);
driver.waitForNextDecision(DECISION_MILLIS);
}
driver.setInput(false, false, false);
applyInput(driver, trace, Input.none(), lastStep, last);
long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall);
long simulationElapsed = elapsed(
last.elapsedMilliseconds(), scenarioStartedAtSimulation);
BotCommandTrace finishedTrace = trace.finish("SIMULATION_BUDGET", lastStep, last);
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
scenario.name + " exhausted its simulation budget; step=" + lastStep
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
+ snapshotState(last),
lastStep);
lastStep, finishedTrace);
}
private static void applyInput(Driver driver, BotCommandTrace.Builder trace, Input input,
String step, GameView.AutomationSnapshot snapshot) {
driver.setInput(input.moveLeft, input.moveRight, input.jump);
trace.input(step, input.moveLeft, input.moveRight, input.jump, snapshot);
}
private static long wallWatchdogMillis(long simulationBudgetMillis) {
@@ -0,0 +1,29 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
import java.io.StringReader;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class LevelCatalogLoaderTest {
@Test
public void versionedIndexLoadsSequentialLevelsThroughResourceBoundary() {
Map<String, String> resources = new HashMap<>();
resources.put("levels/index.json", "{\"formatVersion\":1,\"physicsVersion\":1,"
+ "\"levels\":[\"level-01.json\"]}");
resources.put("levels/level-01.json", LevelJsonLoaderTest.validLevelJson(1));
List<LevelDefinition> levels = LevelCatalogLoader.load(path -> {
String value = resources.get(path);
return value == null ? null : new StringReader(value);
}, "levels/index.json");
assertEquals(1, levels.size());
assertEquals(1, levels.get(0).number);
assertEquals("The Test", levels.get(0).title);
}
}
@@ -24,6 +24,19 @@ public final class LevelCatalogTest {
}
}
@Test
public void bundledCatalogCarriesLocalizedCopyAndVersionedContent() {
LevelDefinition first = LevelCatalog.get(0);
LevelDefinition last = LevelCatalog.get(9);
assertEquals(LevelFormat.CURRENT_FORMAT_VERSION, first.formatVersion);
assertEquals(LevelFormat.CURRENT_PHYSICS_VERSION, first.physicsVersion);
assertEquals("Рыцарь-доброволец", first.titleRu);
assertEquals("Последнее шествие", last.titleRu);
assertFalse(first.hintRu.trim().isEmpty());
assertFalse(last.hintRu.trim().isEmpty());
}
@Test
public void everyLevelHasValidSpawnGoalAndGeometry() {
for (LevelDefinition level : LevelCatalog.all()) {
@@ -0,0 +1,145 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Test;
import java.io.StringReader;
public final class LevelJsonLoaderTest {
@Test
public void versionedJsonLoadsLocalizedLevelThroughPublicContract() {
String json = validLevelJson(1);
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "test-level.json");
assertEquals(1, level.formatVersion);
assertEquals(1, level.physicsVersion);
assertEquals(1, level.number);
assertEquals("The Test", level.title);
assertEquals("Reach the goal.", level.hint);
assertEquals("Испытание", level.titleRu);
assertEquals("Доберитесь до цели.", level.hintRu);
assertEquals(1920f, level.worldWidth, 0f);
assertEquals(1080f, level.worldHeight, 0f);
assertEquals(1, level.platforms.size());
}
@Test
public void unknownFormatVersionIsRejectedWithSourcePath() {
try {
LevelJsonLoader.load(new StringReader(validLevelJson(2)), "future-level.json");
fail("Unknown format versions must fail closed");
} catch (IllegalArgumentException error) {
assertTrue(error.getMessage(), error.getMessage().contains("future-level.json.formatVersion"));
assertTrue(error.getMessage(), error.getMessage().contains("expected 1"));
}
}
@Test
public void doorWithUnknownSignalIsRejectedBeforeEnteringCatalog() {
String json = validLevelJson(1).replace(
"\"hazards\":[],\"switches\":[],\"doors\":[]",
"\"hazards\":[],"
+ "\"switches\":[{\"type\":\"PRESSURE\","
+ "\"box\":{\"x\":540,\"y\":860,\"width\":150,\"height\":20},"
+ "\"signalId\":\"gate\",\"durationSeconds\":0,\"requiredMass\":1}],"
+ "\"doors\":[{\"box\":{\"x\":1110,\"y\":570,\"width\":78,\"height\":310},"
+ "\"requiredSignals\":[\"missing\"]}]");
try {
LevelJsonLoader.load(new StringReader(json), "broken-signal.json");
fail("Unknown signal references must be rejected");
} catch (IllegalArgumentException error) {
assertTrue(error.getMessage(), error.getMessage().contains(
"broken-signal.json.doors[0].requiredSignals[0]"));
assertTrue(error.getMessage(), error.getMessage().contains("missing signal 'missing'"));
}
}
@Test
public void nonPositiveGeometryIsRejectedWithObjectPath() {
String json = validLevelJson(1).replace(
"\"width\":1920,\"height\":200",
"\"width\":0,\"height\":200");
try {
LevelJsonLoader.load(new StringReader(json), "flat-platform.json");
fail("Non-positive platform geometry must be rejected");
} catch (IllegalArgumentException error) {
assertTrue(error.getMessage(), error.getMessage().contains(
"flat-platform.json.platforms[0].box.width"));
assertTrue(error.getMessage(), error.getMessage().contains("must be positive"));
}
}
@Test
public void currentPuzzleObjectsLoadWithoutJavaGeometryFallback() {
String json = validLevelJson(1)
.replace(
"{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}",
"{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200},"
+ "\"travelX\":0,\"travelY\":-300,\"periodSeconds\":5.2,"
+ "\"controlId\":\"lift\"}")
.replace(
"\"hazards\":[],\"switches\":[],\"doors\":[]",
"\"hazards\":[{\"type\":\"SAW\","
+ "\"box\":{\"x\":565,\"y\":760,\"width\":92,\"height\":92},"
+ "\"impulseX\":500,\"impulseY\":-700}],"
+ "\"switches\":[{\"type\":\"PRESSURE\","
+ "\"box\":{\"x\":540,\"y\":860,\"width\":150,\"height\":20},"
+ "\"signalId\":\"lift\",\"durationSeconds\":0,\"requiredMass\":1}],"
+ "\"doors\":[{\"box\":{\"x\":1110,\"y\":570,\"width\":78,\"height\":310},"
+ "\"requiredSignals\":[\"lift\"]}]");
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "mechanics.json");
assertEquals(1, level.platforms.size());
assertEquals(-300f, level.platforms.get(0).travelY, 0f);
assertEquals(5.2f, level.platforms.get(0).periodSeconds, 0f);
assertEquals("lift", level.platforms.get(0).controlId);
assertEquals(LevelDefinition.HazardType.SAW, level.hazards.get(0).type);
assertEquals(500f, level.hazards.get(0).impulseX, 0f);
assertEquals(1, level.switches.size());
assertEquals(1, level.doors.size());
}
@Test
public void timedSwitchRequiresPositiveDuration() {
String json = validLevelJson(1).replace(
"\"hazards\":[],\"switches\":[],\"doors\":[]",
"\"hazards\":[],"
+ "\"switches\":[{\"type\":\"TIMED\","
+ "\"box\":{\"x\":560,\"y\":810,\"width\":90,\"height\":70},"
+ "\"signalId\":\"clock\",\"durationSeconds\":0,\"requiredMass\":1}],"
+ "\"doors\":[]");
try {
LevelJsonLoader.load(new StringReader(json), "zero-timer.json");
fail("A timed switch with no active window must be rejected");
} catch (IllegalArgumentException error) {
assertTrue(error.getMessage(), error.getMessage().contains(
"zero-timer.json.switches[0].durationSeconds"));
assertTrue(error.getMessage(), error.getMessage().contains("must be positive"));
}
}
static String validLevelJson(int formatVersion) {
return "{"
+ "\"formatVersion\":" + formatVersion + ","
+ "\"physicsVersion\":1,"
+ "\"number\":1,"
+ "\"copy\":{"
+ "\"en\":{\"title\":\"The Test\",\"hint\":\"Reach the goal.\"},"
+ "\"ru\":{\"title\":\"Испытание\",\"hint\":\"Доберитесь до цели.\"}},"
+ "\"world\":{\"width\":1920,\"height\":1080},"
+ "\"spawn\":{\"x\":130,\"y\":760},"
+ "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110},"
+ "\"targets\":{\"deaths\":0,\"seconds\":30},"
+ "\"platforms\":[{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}],"
+ "\"hazards\":[],\"switches\":[],\"doors\":[]"
+ "}";
}
}
@@ -28,6 +28,51 @@ public final class LevelPlaytestBotTest {
driver.inputReleases >= 2);
}
@Test
public void resultExposesDeterministicVersionedCommandTrace() {
ScriptedDriver driver = new ScriptedDriver();
LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario
.builder(7, "Trace scenario")
.step("Create a corpse", 1_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() >= 1)
.step("Reach the goal", 1_000L, LevelPlaytestBot.Input.left(),
GameView.AutomationSnapshot::completed)
.build();
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L);
String trace = result.traceJson();
assertEquals(trace, result.traceJson());
assertTrue(trace, trace.contains("\"traceVersion\":1"));
assertTrue(trace, trace.contains("\"level\":7"));
assertTrue(trace, trace.contains("\"levelFormatVersion\":1"));
assertTrue(trace, trace.contains("\"physicsVersion\":1"));
assertTrue(trace, trace.contains("\"scenario\":\"Trace scenario\""));
assertTrue(trace, trace.contains("\"kind\":\"INPUT\""));
assertTrue(trace, trace.contains("\"simulationMs\":"));
assertFalse(trace, trace.contains("wallElapsed"));
assertFalse(trace, trace.contains("device"));
assertTrue(result.diagnostic(), result.diagnostic().contains("traceId=" + result.traceId()));
}
@Test
public void tracingDriverCoversBespokeProductionInputScenarios() {
BotTraceDriver driver = new BotTraceDriver(new ScriptedDriver(), "Bespoke route");
driver.startLevel(8);
driver.step("Board platform");
driver.setInput(false, true, true);
driver.waitForNextDecision(16L);
GameView.AutomationSnapshot snapshot = driver.snapshot();
BotCommandTrace trace = driver.finish("COMPLETED", "Board platform", snapshot);
assertTrue(trace.json(), trace.json().contains("\"scenario\":\"Bespoke route\""));
assertTrue(trace.json(), trace.json().contains("\"step\":\"Board platform\""));
assertTrue(trace.json(), trace.json().contains(
"\"input\":{\"left\":false,\"right\":true,\"jump\":true}"));
assertEquals(64, trace.id().length());
}
@Test
public void sameDriverCanRestartTheCompleteScenarioThreeTimes() {
ScriptedDriver driver = new ScriptedDriver();
+16 -4
View File
@@ -6,7 +6,7 @@
## 1. Текущее состояние
Инженерная часть этапов 2 и 3 завершена:
Инженерная часть этапов 2–4 завершена:
- реализованы 10 последовательных уровней;
- работают движение, прыжок и мобильное multitouch-управление;
@@ -25,6 +25,11 @@
- весь Canvas UI, десять названий и подсказок доступны на русском и английском; язык хранится отдельно и не меняет progress/control settings;
- Stage 3 focused matrix: 14/14 методов без retries; UI contracts GREEN на 20:9, 16:9 и логическом 16:10 tablet viewport;
- final Stage 3 Android 16 matrix: `OK (30 tests)` без filters/retries за `1,409.366 s`; все intended routes 1–10, recovery/stress, lifecycle, restart и UI/persistence contracts GREEN;
- все десять уровней и RU/EN copy перенесены из Java-констант в versioned JSON с `formatVersion=1` и `physicsVersion=1`;
- общий pure-Java loader/validator используется Android runtime и desktop tooling; повреждённая геометрия, версия или ссылка на signal останавливает build до APK packaging;
- deterministic desktop exporter создаёт десять SVG и единый coordinate TSV с byte-identical результатом для неизменного каталога;
- acceptance bots сохраняют versioned command/checkpoint trace и SHA-256 trace ID без wall-clock или прямых mutation-команд;
- final Stage 4 Android 16 matrix: `OK (30 tests)` без filters/retries за `1,096.448 s`; вся Stage 3 gameplay/UI матрица GREEN после JSON-миграции;
- final integrated Android 16 matrix: 16/16 методов, 36 игровых выполнений, lifecycle и restart; wall time `9:32.496`;
- финальный debug APK проверен на Android 16: установка, холодный запуск, ZIP-структура и v1/v2-подписи корректны.
@@ -182,7 +187,7 @@ Performance-оговорка: исторический критерий этап
8. холодный запуск и проверка crash-буфера;
9. ручной smoke-тест уровня 1 и одного сложного комбинированного уровня.
## 5. Завершение этапов 2–3 и следующая итерация
## 5. Завершение этапов 2–4 и следующая итерация
Инженерный scope этапа 2 закрыт: уровни 1–10 проходят intended route по три раза, уровень 8 имеет restart-free recovery, уровень 10 использует все три тела и сохраняет тело на lift-плите, а restart возвращает чистое состояние. Передаваемые результаты — обновлённый проект, проверенный APK и [`STAGE2_QA_REPORT.md`](STAGE2_QA_REPORT.md).
@@ -190,7 +195,14 @@ Performance-оговорка: исторический критерий этап
неблокирующее обучение, полный result flow и RU/EN локализация. Совместный
device-gate проверяет сохранность progress/settings, а UI contracts выполнены на
20:9, 16:9 и tablet-sized экране. Подробный итог находится в
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md). Следующая инженерная итерация — этап
4, перенос описаний уровней в валидируемый формат и инструменты контента.
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md).
Этап 4 завершён: каталог загружается из десяти JSON-ресурсов, общий validator
запускается до Android packaging, а desktop-команда создаёт deterministic SVG/TSV
preview. Боты публикуют versioned trace с идентификатором для диагностики
регрессий; неизменность gameplay подтверждена полным Android regression.
Контракт и evidence находятся в [`STAGE4_CONTENT_FORMAT.md`](STAGE4_CONTENT_FORMAT.md)
и [`STAGE4_QA_REPORT.md`](STAGE4_QA_REPORT.md). Следующая инженерная итерация —
этап 5, самостоятельный художественный стиль, анимации и звук.
Параллельно внешний QA должен выполнить `goon-game-human-playtest`: по две ручные сессии каждого уровня без подсказок разработчика. До получения этого протокола milestone Vertical Slice нельзя называть принятым внешними тестировщиками.
+139
View File
@@ -0,0 +1,139 @@
# Stage 4: формат уровней и контентные инструменты
## Назначение
Все десять уровней находятся в Android-independent модуле `level-format` и
загружаются из `level-format/src/main/resources/levels`. Runtime, unit-тесты и
desktop-команды используют один и тот же JSON parser, catalog loader и validator.
Изменение геометрии или текста не требует изменения игрового Java-кода.
Текущие версии контракта:
- `formatVersion: 1` — схема JSON;
- `physicsVersion: 1` — набор физических допущений, для которого создан уровень;
- bot `traceVersion: 1` — формат журнала acceptance-команд.
Неизвестная версия отклоняется. Поддержка новой версии должна добавляться явно,
вместе с migration/tests, без молчаливого fallback.
## Каталог
`levels/index.json` задаёт точный порядок уровней и содержит те же версии:
```json
{
"formatVersion": 1,
"physicsVersion": 1,
"levels": ["level-01.json", "level-02.json"]
}
```
Имена в каталоге должны быть уникальны, ресурсы должны существовать, а поле
`number` каждого загруженного уровня должно соответствовать позиции в списке.
## Схема уровня
Минимальный характерный пример:
```json
{
"formatVersion": 1,
"physicsVersion": 1,
"number": 3,
"copy": {
"en": {"title": "Dead Weight", "hint": "Leave a body on the plate."},
"ru": {"title": "Мёртвый груз", "hint": "Оставьте тело на плите."}
},
"world": {"width": 2050, "height": 1080},
"spawn": {"x": 130, "y": 760},
"goal": {"x": 1810, "y": 770, "width": 86, "height": 110},
"targets": {"deaths": 1, "seconds": 50},
"platforms": [
{"box": {"x": 0, "y": 880, "width": 2050, "height": 200}}
],
"hazards": [
{"type": "SAW", "box": {"x": 565, "y": 760, "width": 92, "height": 92},
"impulseX": 0, "impulseY": -150}
],
"switches": [
{"type": "PRESSURE", "box": {"x": 540, "y": 860, "width": 150, "height": 20},
"signalId": "gate", "durationSeconds": 0, "requiredMass": 1}
],
"doors": [
{"box": {"x": 1110, "y": 570, "width": 78, "height": 310},
"requiredSignals": ["gate"]}
]
}
```
Поддерживаемые значения:
- hazards: `SPIKES`, `SAW`, `FREEZER`, `ELECTRIC`, `PIT`;
- switches: `PRESSURE`, `TOGGLE`, `TIMED`;
- static platform: только `box`;
- moving platform: `box`, ненулевой `travelX` или `travelY`, положительный
`periodSeconds`, опциональный `controlId`;
- electrical hazard может публиковать `relayId`;
- door перечисляет все `requiredSignals` (AND-семантика).
## Валидация
Валидатор проверяет до упаковки APK:
- известные format/physics versions;
- конечные положительные размеры мира и объектов;
- spawn, goal, платформы, hazards, switches, doors и конечную позицию moving
platform внутри мира;
- наличие хотя бы одной платформы;
- неотрицательный target deaths и положительный target seconds;
- непустой signal ID, положительный required mass и положительную длительность
`TIMED`;
- существование каждого `door.requiredSignals` и `platform.controlId` среди
сигналов switches/relays;
- целостность index, уникальность ресурсов и последовательную нумерацию.
Ошибка содержит путь, например:
```text
level-03.json.doors[0].requiredSignals[0] references missing signal 'missing'
```
Команда:
```sh
./gradlew :level-format:validateLevels
```
`app:preBuild` зависит от неё. Для проверки временной копии каталога можно
передать `-PlevelDirectory=/absolute/path/to/levels`.
## Детерминированный preview
```sh
./gradlew :level-format:previewLevels
```
Результат находится в `level-format/build/level-previews`:
- `level-01.svg` … `level-10.svg` — world-space схема spawn/goal, платформ,
hazards, switches и doors;
- `catalog.tsv` — координаты, размеры, travel и signal/control references.
Вывод использует стабильный порядок и locale-independent числа. Два запуска на
неизменном каталоге должны давать byte-identical файлы.
## Bot trace
Каждый generic и level-8 acceptance-run возвращает `traceJson()` и `traceId()`.
Trace содержит:
- `traceVersion`, level, `levelFormatVersion`, `physicsVersion` и scenario;
- outcome;
- события `START`, `INPUT`, `CHECKPOINT`, `FINISH`;
- только simulation time, публичный input и наблюдаемое состояние игрока/дверей.
Wall-clock, device identifiers и прямые mutation-команды не записываются.
`traceId` — SHA-256 точного UTF-8 JSON и всегда включён в failure diagnostic.
Stage 4 сохраняет воспроизводимый артефакт расследования, но намеренно не добавляет
runtime replay executor.
+90
View File
@@ -0,0 +1,90 @@
# Stage 4 QA report
Дата: 18 августа 2026 года
Версия: `0.7.0-stage4-content` (`versionCode 8`)
## Результат
Этап 4 «Контентная архитектура и инструменты» завершён инженерно:
- уровни 1–10 перенесены из Java-констант в versioned JSON;
- Android runtime и desktop tools используют общий pure-Java loader/validator;
- build отклоняет повреждённый контент до APK packaging;
- deterministic SVG/TSV preview экспортирует все десять уровней;
- acceptance bots формируют versioned JSON trace и SHA-256 trace ID;
- исходная геометрия, физика, тексты и gameplay progression сохранены;
- полный накопительный Android regression прошёл без filters/retries.
## TDD evidence
Последовательные RED→GREEN slices:
1. отсутствовали loader и versioned fields → добавлен JSON loader и immutable model;
2. неизвестный format принимался → fail-closed version contract;
3. ссылка двери на отсутствующий signal не диагностировалась → path-specific reference check;
4. platform с нулевой шириной принималась → geometry/world-bounds validation;
5. parser не поддерживал полный набор механик → vocabulary всех текущих уровней;
6. отсутствовал index/catalog loader → ordered resource catalog;
7. RU copy возвращала EN fallback → localized copy перенесена в JSON;
8. отсутствовала desktop-команда → validate/preview tool;
9. preview command возвращала usage → byte-identical SVG/TSV output;
10. отсутствовала Gradle task → validation wired before `app:preBuild`;
11. отсутствовал versioned bot trace → deterministic Result trace + SHA-256 ID;
12. bespoke level-8 runner не трассировался → reusable tracing driver;
13. `TIMED` с нулевой длительностью принимался → positive duration validation.
Отдельно доказан build-time отказ на временной копии каталога с ошибкой:
```text
level-03.json.doors[0].requiredSignals[0] references missing signal 'missing'
```
Канонический каталог при этом не изменялся.
## Автоматические проверки
Integrated JVM/build gate:
```sh
./gradlew :level-format:test testDebugUnitTest lintDebug \
assembleDebug assembleDebugAndroidTest :level-format:previewLevels
```
Результат: `BUILD SUCCESSFUL`; parser, catalog, validator, tooling, localization,
bot trace и прежние unit contracts GREEN. Выполнено 52 app JVM-теста и 2
`level-format` теста, failures/errors = 0. APK содержит `levels/index.json` и
десять JSON-файлов уровней.
Device acceptance:
- AVD: `medium_phone`, Android 16 / API 36, 1080×2400;
- команда: unfiltered `am instrument` всего debug test APK;
- результат: `OK (30 tests)`;
- wall time: `1,096.448 s`;
- один процесс, без class/method filters и retries;
- GREEN: intended routes 1–10, level-8 stress/recovery, level-10 delayed
traversal, level-5, lifecycle, restart, controls, accessibility, RU/EN,
onboarding/result и Stage 3 acceptance contracts.
Первая попытка запуска на принудительном CPU SwiftShader была остановлена как
неограниченная ошибка среды: эмулятор расходовал около 678% host CPU и дошёл
только до второго теста за восемь минут. Она не содержала product failure и не
считается RED/GREEN или retry. Финальный результат получен на стандартном
host-rendered профиле AVD.
Final APK inspection:
- `unzip -t`: ошибок нет;
- package: `com.projectsacrifice.game.debug`;
- `versionCode=8`, `versionName=0.7.0-stage4-content-debug`;
- `apksigner`: v1=true, v2=true, один стандартный Android Debug signer;
- clean install: `Success`;
- cold start: `Status: ok`, `LaunchState: COLD`, 401 ms;
- `MainActivity`: `RESUMED`, visible, reportedDrawn;
- Android crash buffer после запуска пуст.
## Внешние gates
Как и в предыдущих этапах, automation доказывает техническую проходимость, но не
понятность головоломок новому игроку. Bead `goon-game-human-playtest` остаётся
открытым: требуются две независимые ручные сессии на каждый уровень.
+43
View File
@@ -0,0 +1,43 @@
plugins {
id 'java-library'
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
dependencies {
testImplementation 'junit:junit:4.13.2'
}
def levelsDirectory = layout.projectDirectory.dir('src/main/resources/levels')
def configuredLevelsDirectory = providers.gradleProperty('levelDirectory')
.map { file(it) }
.orElse(levelsDirectory.asFile)
tasks.register('validateLevels', JavaExec) {
group = 'verification'
description = 'Validates every versioned level resource before Android packaging.'
dependsOn classes
classpath = sourceSets.main.runtimeClasspath
mainClass = 'com.projectsacrifice.game.LevelContentTool'
inputs.dir configuredLevelsDirectory
doFirst {
setArgs(['validate', configuredLevelsDirectory.get().absolutePath])
}
}
tasks.register('previewLevels', JavaExec) {
group = 'verification'
description = 'Exports deterministic SVG and TSV previews for every level.'
dependsOn classes
classpath = sourceSets.main.runtimeClasspath
mainClass = 'com.projectsacrifice.game.LevelContentTool'
inputs.dir configuredLevelsDirectory
outputs.dir layout.buildDirectory.dir('level-previews')
doFirst {
setArgs(['preview', configuredLevelsDirectory.get().absolutePath,
layout.buildDirectory.dir('level-previews').get().asFile.absolutePath])
}
}
@@ -0,0 +1,41 @@
package com.projectsacrifice.game;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
/** Immutable production catalog loaded from validated versioned resources. */
public final class LevelCatalog {
private static final String INDEX_RESOURCE = "levels/index.json";
private static final List<LevelDefinition> LEVELS = loadBundled();
private LevelCatalog() {}
public static List<LevelDefinition> all() {
return LEVELS;
}
public static LevelDefinition get(int index) {
if (index < 0 || index >= LEVELS.size()) {
throw new IllegalArgumentException("Unknown level index: " + index);
}
return LEVELS.get(index);
}
public static int formatVersion() {
return LevelFormat.CURRENT_FORMAT_VERSION;
}
public static int physicsVersion() {
return LevelFormat.CURRENT_PHYSICS_VERSION;
}
private static List<LevelDefinition> loadBundled() {
ClassLoader classLoader = LevelCatalog.class.getClassLoader();
return LevelCatalogLoader.load(path -> {
InputStream stream = classLoader.getResourceAsStream(path);
return stream == null ? null : new InputStreamReader(stream, StandardCharsets.UTF_8);
}, INDEX_RESOURCE);
}
}
@@ -0,0 +1,71 @@
package com.projectsacrifice.game;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** Loads a complete ordered catalog from an index and per-level resources. */
public final class LevelCatalogLoader {
private LevelCatalogLoader() {}
public static List<LevelDefinition> load(LevelResourceSource source, String indexPath) {
if (source == null) throw new IllegalArgumentException("level resource source is required");
String index = requiredPath(indexPath);
List<String> entries;
try (Reader reader = open(source, index)) {
entries = LevelJsonLoader.loadIndex(reader, index);
} catch (IOException error) {
throw new IllegalArgumentException(index + " could not be closed", error);
}
List<LevelDefinition> result = new ArrayList<>();
Set<Integer> numbers = new HashSet<>();
String directory = directory(index);
for (int i = 0; i < entries.size(); i++) {
String path = directory + entries.get(i);
LevelDefinition level;
try (Reader reader = open(source, path)) {
level = LevelJsonLoader.load(reader, path);
} catch (IOException error) {
throw new IllegalArgumentException(path + " could not be closed", error);
}
if (!numbers.add(level.number)) {
throw new IllegalArgumentException(index + ".levels[" + i
+ "] duplicates level number " + level.number);
}
int expected = i + 1;
if (level.number != expected) {
throw new IllegalArgumentException(index + ".levels[" + i + "] expected level number "
+ expected + " but was " + level.number);
}
result.add(level);
}
return Collections.unmodifiableList(result);
}
private static Reader open(LevelResourceSource source, String path) {
try {
Reader reader = source.open(path);
if (reader == null) throw new IllegalArgumentException(path + " resource is missing");
return reader;
} catch (IOException error) {
throw new IllegalArgumentException(path + " could not be opened", error);
}
}
private static String requiredPath(String path) {
if (path == null || path.trim().isEmpty()) {
throw new IllegalArgumentException("level index path is required");
}
return path;
}
private static String directory(String path) {
int slash = path.lastIndexOf('/');
return slash < 0 ? "" : path.substring(0, slash + 1);
}
}
@@ -0,0 +1,162 @@
package com.projectsacrifice.game;
import java.io.IOException;
import java.io.PrintStream;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
/** Desktop entrypoint for deterministic validation and content export. */
public final class LevelContentTool {
private LevelContentTool() {}
public static void main(String[] args) {
int exitCode = run(args, System.out, System.err);
if (exitCode != 0) throw new IllegalStateException("Level content tool failed with exit " + exitCode);
}
public static int run(String[] args, PrintStream output, PrintStream errors) {
if (output == null || errors == null) throw new IllegalArgumentException("output streams are required");
try {
if (args != null && args.length == 2 && "validate".equals(args[0])) {
List<LevelDefinition> levels = loadDirectory(args[1]);
output.println("Validated " + levels.size() + " level(s); format="
+ LevelFormat.CURRENT_FORMAT_VERSION + "; physics="
+ LevelFormat.CURRENT_PHYSICS_VERSION);
return 0;
}
if (args != null && args.length == 3 && "preview".equals(args[0])) {
List<LevelDefinition> levels = loadDirectory(args[1]);
Path destination = Paths.get(args[2]).toAbsolutePath().normalize();
writePreview(levels, destination);
output.println("Exported " + levels.size() + " level preview(s) to " + destination);
return 0;
}
errors.println("Usage: validate <levels-directory> | preview <levels-directory> <output-directory>");
return 2;
} catch (IllegalArgumentException | IOException error) {
errors.println(error.getMessage());
return 1;
}
}
private static List<LevelDefinition> loadDirectory(String value) {
Path directory = Paths.get(value).toAbsolutePath().normalize();
return LevelCatalogLoader.load(new DirectorySource(directory), "index.json");
}
private static void writePreview(List<LevelDefinition> levels, Path destination) throws IOException {
Files.createDirectories(destination);
StringBuilder coordinates = new StringBuilder(
"level\tkind\tindex\ttype\tx\ty\twidth\theight\ttravelX\ttravelY\tcontrolOrSignals\n");
for (LevelDefinition level : levels) {
Files.writeString(destination.resolve(String.format("level-%02d.svg", level.number)),
svg(level), StandardCharsets.UTF_8);
coordinates.append(coordinates(level));
}
Files.writeString(destination.resolve("catalog.tsv"), coordinates.toString(), StandardCharsets.UTF_8);
}
private static String svg(LevelDefinition level) {
StringBuilder value = new StringBuilder();
value.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
.append("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ")
.append(number(level.worldWidth)).append(' ').append(number(level.worldHeight))
.append("\" data-format-version=\"").append(level.formatVersion)
.append("\" data-physics-version=\"").append(level.physicsVersion).append("\">\n")
.append(" <title>").append(xml(level.number + " — " + level.title)).append("</title>\n")
.append(" <rect width=\"100%\" height=\"100%\" fill=\"#111827\"/>\n");
for (int i = 0; i < level.platforms.size(); i++) {
rect(value, "platform", i, level.platforms.get(i).box, "#64748b");
}
for (int i = 0; i < level.hazards.size(); i++) {
rect(value, "hazard-" + level.hazards.get(i).type, i,
level.hazards.get(i).box, "#ef4444");
}
for (int i = 0; i < level.switches.size(); i++) {
rect(value, "switch-" + level.switches.get(i).type, i,
level.switches.get(i).box, "#eab308");
}
for (int i = 0; i < level.doors.size(); i++) {
rect(value, "door", i, level.doors.get(i).box, "#a855f7");
}
rect(value, "spawn", 0, new LevelDefinition.Box(level.spawnX, level.spawnY, 62f, 100f), "#22c55e");
rect(value, "goal", 0, level.goal, "#38bdf8");
value.append("</svg>\n");
return value.toString();
}
private static void rect(StringBuilder output, String kind, int index,
LevelDefinition.Box box, String color) {
output.append(" <rect data-kind=\"").append(kind).append("\" data-index=\"")
.append(index).append("\" x=\"").append(number(box.x)).append("\" y=\"")
.append(number(box.y)).append("\" width=\"").append(number(box.width))
.append("\" height=\"").append(number(box.height)).append("\" fill=\"")
.append(color).append("\" fill-opacity=\"0.78\"/>\n");
}
private static String coordinates(LevelDefinition level) {
StringBuilder value = new StringBuilder();
row(value, level.number, "spawn", 0, "PLAYER", new LevelDefinition.Box(
level.spawnX, level.spawnY, 62f, 100f), 0f, 0f, "");
row(value, level.number, "goal", 0, "GOAL", level.goal, 0f, 0f, "");
for (int i = 0; i < level.platforms.size(); i++) {
LevelDefinition.Platform item = level.platforms.get(i);
row(value, level.number, "platform", i, item.isMoving() ? "MOVING" : "STATIC",
item.box, item.travelX, item.travelY, item.controlId);
}
for (int i = 0; i < level.hazards.size(); i++) {
LevelDefinition.Hazard item = level.hazards.get(i);
row(value, level.number, "hazard", i, item.type.name(), item.box,
item.impulseX, item.impulseY, item.relayId);
}
for (int i = 0; i < level.switches.size(); i++) {
LevelDefinition.Switch item = level.switches.get(i);
row(value, level.number, "switch", i, item.type.name(), item.box,
0f, 0f, item.signalId);
}
for (int i = 0; i < level.doors.size(); i++) {
LevelDefinition.Door item = level.doors.get(i);
row(value, level.number, "door", i, "DOOR", item.box,
0f, 0f, String.join("+", item.requiredSignals));
}
return value.toString();
}
private static void row(StringBuilder output, int level, String kind, int index, String type,
LevelDefinition.Box box, float travelX, float travelY, String control) {
output.append(level).append('\t').append(kind).append('\t').append(index).append('\t')
.append(type).append('\t').append(number(box.x)).append('\t')
.append(number(box.y)).append('\t').append(number(box.width)).append('\t')
.append(number(box.height)).append('\t').append(number(travelX)).append('\t')
.append(number(travelY)).append('\t').append(control == null ? "" : control).append('\n');
}
private static String number(float value) {
if (value == (long) value) return Long.toString((long) value);
return Float.toString(value);
}
private static String xml(String value) {
return value.replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace("\"", "&quot;");
}
private static final class DirectorySource implements LevelResourceSource {
private final Path directory;
DirectorySource(Path directory) {
this.directory = directory;
}
@Override
public Reader open(String path) throws IOException {
Path resolved = directory.resolve(path).normalize();
if (!resolved.startsWith(directory) || !Files.isRegularFile(resolved)) return null;
return Files.newBufferedReader(resolved, StandardCharsets.UTF_8);
}
}
}
@@ -95,9 +95,14 @@ public final class LevelDefinition {
}
public final int number;
public final int formatVersion;
public final int physicsVersion;
public final String title;
public final String hint;
public final String titleRu;
public final String hintRu;
public final float worldWidth;
public final float worldHeight;
public final float spawnX;
public final float spawnY;
public final Box goal;
@@ -123,10 +128,40 @@ public final class LevelDefinition {
List<Switch> switches,
List<Door> doors
) {
this(1, 1, number, title, hint, title, hint, worldWidth, 1080f,
spawnX, spawnY, goal, targetDeaths, targetSeconds,
platforms, hazards, switches, doors);
}
public LevelDefinition(
int formatVersion,
int physicsVersion,
int number,
String title,
String hint,
String titleRu,
String hintRu,
float worldWidth,
float worldHeight,
float spawnX,
float spawnY,
Box goal,
int targetDeaths,
int targetSeconds,
List<Platform> platforms,
List<Hazard> hazards,
List<Switch> switches,
List<Door> doors
) {
this.formatVersion = formatVersion;
this.physicsVersion = physicsVersion;
this.number = number;
this.title = title;
this.hint = hint;
this.titleRu = titleRu;
this.hintRu = hintRu;
this.worldWidth = worldWidth;
this.worldHeight = worldHeight;
this.spawnX = spawnX;
this.spawnY = spawnY;
this.goal = goal;
@@ -0,0 +1,9 @@
package com.projectsacrifice.game;
/** Version boundary shared by level content, validation, previews, and bot traces. */
public final class LevelFormat {
public static final int CURRENT_FORMAT_VERSION = 1;
public static final int CURRENT_PHYSICS_VERSION = 1;
private LevelFormat() {}
}
@@ -0,0 +1,408 @@
package com.projectsacrifice.game;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.HashSet;
import java.util.Set;
/** Loads one immutable level from the versioned Stage 4 JSON contract. */
public final class LevelJsonLoader {
private LevelJsonLoader() {}
public static LevelDefinition load(Reader reader, String sourceName) {
String source = sourceName == null ? "<level>" : sourceName;
Map<String, Object> root = object(new JsonValueParser(reader, source).parse(), source);
Map<String, Object> copy = object(required(root, "copy", source), source + ".copy");
Map<String, Object> english = object(required(copy, "en", source + ".copy"), source + ".copy.en");
Map<String, Object> russian = object(required(copy, "ru", source + ".copy"), source + ".copy.ru");
Map<String, Object> world = object(required(root, "world", source), source + ".world");
Map<String, Object> spawn = object(required(root, "spawn", source), source + ".spawn");
Map<String, Object> targets = object(required(root, "targets", source), source + ".targets");
List<LevelDefinition.Platform> platforms = new ArrayList<>();
List<Object> platformValues = array(required(root, "platforms", source), source + ".platforms");
for (int i = 0; i < platformValues.size(); i++) {
String path = source + ".platforms[" + i + "]";
Map<String, Object> value = object(platformValues.get(i), path);
platforms.add(new LevelDefinition.Platform(
box(required(value, "box", path), path + ".box"),
optionalDecimal(value, "travelX", path, 0f),
optionalDecimal(value, "travelY", path, 0f),
optionalDecimal(value, "periodSeconds", path, 0f),
optionalText(value, "controlId", path)));
}
List<LevelDefinition.Hazard> hazards = hazards(root, source);
List<LevelDefinition.Switch> switches = switches(root, source);
List<LevelDefinition.Door> doors = doors(root, source);
return LevelValidator.validate(new LevelDefinition(
integer(root, "formatVersion", source),
integer(root, "physicsVersion", source),
integer(root, "number", source),
text(english, "title", source + ".copy.en"),
text(english, "hint", source + ".copy.en"),
text(russian, "title", source + ".copy.ru"),
text(russian, "hint", source + ".copy.ru"),
decimal(world, "width", source + ".world"),
decimal(world, "height", source + ".world"),
decimal(spawn, "x", source + ".spawn"),
decimal(spawn, "y", source + ".spawn"),
box(required(root, "goal", source), source + ".goal"),
integer(targets, "deaths", source + ".targets"),
integer(targets, "seconds", source + ".targets"),
platforms,
hazards,
switches,
doors), source);
}
static List<String> loadIndex(Reader reader, String sourceName) {
String source = sourceName == null ? "<levels-index>" : sourceName;
Map<String, Object> root = object(new JsonValueParser(reader, source).parse(), source);
int formatVersion = integer(root, "formatVersion", source);
if (formatVersion != LevelFormat.CURRENT_FORMAT_VERSION) {
throw invalid(source + ".formatVersion", "expected "
+ LevelFormat.CURRENT_FORMAT_VERSION + " but was " + formatVersion);
}
int physicsVersion = integer(root, "physicsVersion", source);
if (physicsVersion != LevelFormat.CURRENT_PHYSICS_VERSION) {
throw invalid(source + ".physicsVersion", "expected "
+ LevelFormat.CURRENT_PHYSICS_VERSION + " but was " + physicsVersion);
}
List<Object> values = array(required(root, "levels", source), source + ".levels");
if (values.isEmpty()) throw invalid(source + ".levels", "must not be empty");
List<String> result = new ArrayList<>();
Set<String> unique = new HashSet<>();
for (int i = 0; i < values.size(); i++) {
Object value = values.get(i);
String path = source + ".levels[" + i + "]";
if (!(value instanceof String) || ((String) value).trim().isEmpty()) {
throw invalid(path, "must be a non-empty string");
}
String entry = (String) value;
if (entry.contains("/") || entry.contains("\\") || entry.equals("..")) {
throw invalid(path, "must be a file name relative to the index");
}
if (!unique.add(entry)) throw invalid(path, "duplicates resource '" + entry + "'");
result.add(entry);
}
return Collections.unmodifiableList(result);
}
private static List<LevelDefinition.Hazard> hazards(Map<String, Object> root, String source) {
List<Object> values = array(required(root, "hazards", source), source + ".hazards");
List<LevelDefinition.Hazard> result = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
String path = source + ".hazards[" + i + "]";
Map<String, Object> value = object(values.get(i), path);
result.add(new LevelDefinition.Hazard(
hazardType(text(value, "type", path), path + ".type"),
box(required(value, "box", path), path + ".box"),
optionalText(value, "relayId", path),
optionalDecimal(value, "impulseX", path, 0f),
optionalDecimal(value, "impulseY", path, 0f)));
}
return result;
}
private static List<LevelDefinition.Switch> switches(Map<String, Object> root, String source) {
List<Object> values = array(required(root, "switches", source), source + ".switches");
List<LevelDefinition.Switch> result = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
String path = source + ".switches[" + i + "]";
Map<String, Object> value = object(values.get(i), path);
result.add(new LevelDefinition.Switch(
switchType(text(value, "type", path), path + ".type"),
box(required(value, "box", path), path + ".box"),
text(value, "signalId", path),
decimal(value, "durationSeconds", path),
integer(value, "requiredMass", path)));
}
return result;
}
private static List<LevelDefinition.Door> doors(Map<String, Object> root, String source) {
List<Object> values = array(required(root, "doors", source), source + ".doors");
List<LevelDefinition.Door> result = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
String path = source + ".doors[" + i + "]";
Map<String, Object> value = object(values.get(i), path);
List<Object> rawSignals = array(required(value, "requiredSignals", path),
path + ".requiredSignals");
String[] signals = new String[rawSignals.size()];
for (int signal = 0; signal < rawSignals.size(); signal++) {
Object raw = rawSignals.get(signal);
if (!(raw instanceof String)) {
throw invalid(path + ".requiredSignals[" + signal + "]", "must be a string");
}
signals[signal] = (String) raw;
}
result.add(new LevelDefinition.Door(
box(required(value, "box", path), path + ".box"), signals));
}
return result;
}
private static LevelDefinition.SwitchType switchType(String value, String path) {
try {
return LevelDefinition.SwitchType.valueOf(value);
} catch (IllegalArgumentException error) {
throw invalid(path, "has unknown switch type '" + value + "'");
}
}
private static LevelDefinition.HazardType hazardType(String value, String path) {
try {
return LevelDefinition.HazardType.valueOf(value);
} catch (IllegalArgumentException error) {
throw invalid(path, "has unknown hazard type '" + value + "'");
}
}
private static void requireEmptyArray(Map<String, Object> parent, String key, String path) {
List<Object> values = array(required(parent, key, path), path + "." + key);
if (!values.isEmpty()) {
throw invalid(path + "." + key, "is not supported by this format slice yet");
}
}
private static LevelDefinition.Box box(Object value, String path) {
Map<String, Object> box = object(value, path);
return new LevelDefinition.Box(
decimal(box, "x", path),
decimal(box, "y", path),
decimal(box, "width", path),
decimal(box, "height", path));
}
private static Object required(Map<String, Object> value, String key, String path) {
if (!value.containsKey(key)) throw invalid(path + "." + key, "is required");
return value.get(key);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> object(Object value, String path) {
if (!(value instanceof Map)) throw invalid(path, "must be an object");
return (Map<String, Object>) value;
}
@SuppressWarnings("unchecked")
private static List<Object> array(Object value, String path) {
if (!(value instanceof List)) throw invalid(path, "must be an array");
return (List<Object>) value;
}
private static String text(Map<String, Object> value, String key, String path) {
Object item = required(value, key, path);
if (!(item instanceof String)) throw invalid(path + "." + key, "must be a string");
return (String) item;
}
private static int integer(Map<String, Object> value, String key, String path) {
double number = number(required(value, key, path), path + "." + key);
if (number != Math.rint(number) || number < Integer.MIN_VALUE || number > Integer.MAX_VALUE) {
throw invalid(path + "." + key, "must be an integer");
}
return (int) number;
}
private static float decimal(Map<String, Object> value, String key, String path) {
return (float) number(required(value, key, path), path + "." + key);
}
private static float optionalDecimal(Map<String, Object> value, String key, String path,
float fallback) {
return value.containsKey(key) ? (float) number(value.get(key), path + "." + key) : fallback;
}
private static String optionalText(Map<String, Object> value, String key, String path) {
if (!value.containsKey(key) || value.get(key) == null) return null;
Object item = value.get(key);
if (!(item instanceof String)) throw invalid(path + "." + key, "must be a string or null");
return (String) item;
}
private static double number(Object value, String path) {
if (!(value instanceof Number)) throw invalid(path, "must be a number");
double result = ((Number) value).doubleValue();
if (!Double.isFinite(result)) throw invalid(path, "must be finite");
return result;
}
private static IllegalArgumentException invalid(String path, String message) {
return new IllegalArgumentException(path + " " + message);
}
private static final class JsonValueParser {
private final String input;
private final String source;
private int index;
JsonValueParser(Reader reader, String source) {
this.input = read(reader, source);
this.source = source;
}
Object parse() {
Object value = value();
whitespace();
if (index != input.length()) fail("unexpected trailing content");
return value;
}
private Object value() {
whitespace();
if (index >= input.length()) fail("unexpected end of input");
char c = input.charAt(index);
if (c == '{') return objectValue();
if (c == '[') return arrayValue();
if (c == '"') return string();
if (c == '-' || (c >= '0' && c <= '9')) return numberValue();
if (input.startsWith("true", index)) { index += 4; return Boolean.TRUE; }
if (input.startsWith("false", index)) { index += 5; return Boolean.FALSE; }
if (input.startsWith("null", index)) { index += 4; return null; }
fail("unexpected token");
return null;
}
private Map<String, Object> objectValue() {
index++;
Map<String, Object> result = new LinkedHashMap<>();
whitespace();
if (consume('}')) return result;
while (true) {
whitespace();
if (index >= input.length() || input.charAt(index) != '"') fail("object key must be a string");
String key = string();
if (result.containsKey(key)) fail("duplicate object key '" + key + "'");
whitespace();
expect(':');
result.put(key, value());
whitespace();
if (consume('}')) return result;
expect(',');
}
}
private List<Object> arrayValue() {
index++;
List<Object> result = new ArrayList<>();
whitespace();
if (consume(']')) return result;
while (true) {
result.add(value());
whitespace();
if (consume(']')) return result;
expect(',');
}
}
private String string() {
expect('"');
StringBuilder result = new StringBuilder();
while (index < input.length()) {
char c = input.charAt(index++);
if (c == '"') return result.toString();
if (c == '\\') {
if (index >= input.length()) fail("unfinished escape");
char escape = input.charAt(index++);
switch (escape) {
case '"': result.append('"'); break;
case '\\': result.append('\\'); break;
case '/': result.append('/'); break;
case 'b': result.append('\b'); break;
case 'f': result.append('\f'); break;
case 'n': result.append('\n'); break;
case 'r': result.append('\r'); break;
case 't': result.append('\t'); break;
case 'u': result.append(unicode()); break;
default: fail("unsupported escape");
}
} else {
if (c < 0x20) fail("control character in string");
result.append(c);
}
}
fail("unterminated string");
return null;
}
private char unicode() {
if (index + 4 > input.length()) fail("unfinished unicode escape");
int value = 0;
for (int i = 0; i < 4; i++) {
int digit = Character.digit(input.charAt(index++), 16);
if (digit < 0) fail("invalid unicode escape");
value = value * 16 + digit;
}
return (char) value;
}
private Number numberValue() {
int start = index;
if (consume('-') && index >= input.length()) fail("invalid number");
if (consume('0')) {
if (index < input.length() && Character.isDigit(input.charAt(index))) fail("leading zero in number");
} else {
digits();
}
if (consume('.')) digits();
if (consume('e') || consume('E')) {
consume('+');
consume('-');
digits();
}
try {
return Double.valueOf(input.substring(start, index));
} catch (NumberFormatException error) {
fail("invalid number");
return null;
}
}
private void digits() {
int start = index;
while (index < input.length() && Character.isDigit(input.charAt(index))) index++;
if (start == index) fail("expected digit");
}
private void whitespace() {
while (index < input.length() && Character.isWhitespace(input.charAt(index))) index++;
}
private void expect(char expected) {
if (!consume(expected)) fail("expected '" + expected + "'");
}
private boolean consume(char expected) {
if (index < input.length() && input.charAt(index) == expected) {
index++;
return true;
}
return false;
}
private void fail(String message) {
throw invalid(source + "@" + index, message);
}
private static String read(Reader reader, String source) {
if (reader == null) throw invalid(source, "reader is required");
StringBuilder result = new StringBuilder();
char[] buffer = new char[2048];
try {
int count;
while ((count = reader.read(buffer)) != -1) result.append(buffer, 0, count);
} catch (IOException error) {
throw new IllegalArgumentException(source + " could not be read", error);
}
return result.toString();
}
}
}
@@ -0,0 +1,10 @@
package com.projectsacrifice.game;
import java.io.IOException;
import java.io.Reader;
/** Boundary used by Android resources, desktop files, and in-memory tests. */
@FunctionalInterface
public interface LevelResourceSource {
Reader open(String path) throws IOException;
}
@@ -0,0 +1,118 @@
package com.projectsacrifice.game;
import java.util.HashSet;
import java.util.Set;
/** Applies the fail-closed domain contract to loaded level content. */
public final class LevelValidator {
private LevelValidator() {}
public static LevelDefinition validate(LevelDefinition level, String sourceName) {
String source = sourceName == null ? "<level>" : sourceName;
if (level.formatVersion != LevelFormat.CURRENT_FORMAT_VERSION) {
throw invalid(source + ".formatVersion", "expected "
+ LevelFormat.CURRENT_FORMAT_VERSION + " but was " + level.formatVersion);
}
if (level.physicsVersion != LevelFormat.CURRENT_PHYSICS_VERSION) {
throw invalid(source + ".physicsVersion", "expected "
+ LevelFormat.CURRENT_PHYSICS_VERSION + " but was " + level.physicsVersion);
}
positive(level.worldWidth, source + ".world.width");
positive(level.worldHeight, source + ".world.height");
coordinate(level.spawnX, level.worldWidth, source + ".spawn.x");
coordinate(level.spawnY, level.worldHeight, source + ".spawn.y");
box(level.goal, level, source + ".goal");
if (level.platforms.isEmpty()) {
throw invalid(source + ".platforms", "must contain at least one platform");
}
for (int i = 0; i < level.platforms.size(); i++) {
LevelDefinition.Platform platform = level.platforms.get(i);
String path = source + ".platforms[" + i + "]";
box(platform.box, level, path + ".box");
if (platform.isMoving()) {
positive(platform.periodSeconds, path + ".periodSeconds");
LevelDefinition.Box destination = new LevelDefinition.Box(
platform.box.x + platform.travelX,
platform.box.y + platform.travelY,
platform.box.width,
platform.box.height);
box(destination, level, path + ".destinationBox");
}
}
for (int i = 0; i < level.hazards.size(); i++) {
box(level.hazards.get(i).box, level, source + ".hazards[" + i + "].box");
}
for (int i = 0; i < level.switches.size(); i++) {
LevelDefinition.Switch item = level.switches.get(i);
String path = source + ".switches[" + i + "]";
box(item.box, level, path + ".box");
if (item.signalId == null || item.signalId.trim().isEmpty()) {
throw invalid(path + ".signalId", "must not be blank");
}
if (item.requiredMass <= 0) {
throw invalid(path + ".requiredMass", "must be positive");
}
if (item.type == LevelDefinition.SwitchType.TIMED && item.durationSeconds <= 0f) {
throw invalid(path + ".durationSeconds", "must be positive for TIMED switch");
}
if (item.type != LevelDefinition.SwitchType.TIMED && item.durationSeconds < 0f) {
throw invalid(path + ".durationSeconds", "must not be negative");
}
}
for (int i = 0; i < level.doors.size(); i++) {
box(level.doors.get(i).box, level, source + ".doors[" + i + "].box");
}
if (level.targetDeaths < 0) throw invalid(source + ".targets.deaths", "must not be negative");
if (level.targetSeconds <= 0) throw invalid(source + ".targets.seconds", "must be positive");
Set<String> signalSources = new HashSet<>();
for (LevelDefinition.Switch item : level.switches) signalSources.add(item.signalId);
for (LevelDefinition.Hazard hazard : level.hazards) {
if (hazard.relayId != null) signalSources.add(hazard.relayId);
}
for (int doorIndex = 0; doorIndex < level.doors.size(); doorIndex++) {
LevelDefinition.Door door = level.doors.get(doorIndex);
for (int signalIndex = 0; signalIndex < door.requiredSignals.size(); signalIndex++) {
String required = door.requiredSignals.get(signalIndex);
if (!signalSources.contains(required)) {
throw invalid(source + ".doors[" + doorIndex + "].requiredSignals["
+ signalIndex + "]", "references missing signal '" + required + "'");
}
}
}
for (int platformIndex = 0; platformIndex < level.platforms.size(); platformIndex++) {
String controlId = level.platforms.get(platformIndex).controlId;
if (controlId != null && !signalSources.contains(controlId)) {
throw invalid(source + ".platforms[" + platformIndex + "].controlId",
"references missing signal '" + controlId + "'");
}
}
return level;
}
private static void box(LevelDefinition.Box box, LevelDefinition level, String path) {
coordinate(box.x, level.worldWidth, path + ".x");
coordinate(box.y, level.worldHeight, path + ".y");
positive(box.width, path + ".width");
positive(box.height, path + ".height");
if (box.right() > level.worldWidth) {
throw invalid(path + ".width", "extends beyond world width " + level.worldWidth);
}
if (box.bottom() > level.worldHeight) {
throw invalid(path + ".height", "extends beyond world height " + level.worldHeight);
}
}
private static void coordinate(float value, float limit, String path) {
if (!Float.isFinite(value) || value < 0f || value >= limit) {
throw invalid(path, "must be finite and inside [0," + limit + ")");
}
}
private static void positive(float value, String path) {
if (!Float.isFinite(value) || value <= 0f) throw invalid(path, "must be positive and finite");
}
private static IllegalArgumentException invalid(String path, String message) {
return new IllegalArgumentException(path + " " + message);
}
}
@@ -0,0 +1 @@
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json"]}
@@ -0,0 +1,11 @@
{
"formatVersion":1,"physicsVersion":1,"number":1,
"copy":{"en":{"title":"The Volunteer","hint":"Reach the relic. Arrows move; the winged button jumps."},"ru":{"title":"Рыцарь-доброволец","hint":"Доберитесь до реликвии. Стрелки двигают; кнопка с крылом — прыжок."}},
"world":{"width":1920,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":1690,"y":770,"width":86,"height":110},"targets":{"deaths":0,"seconds":30},
"platforms":[
{"box":{"x":0,"y":880,"width":1920,"height":200}},
{"box":{"x":640,"y":760,"width":230,"height":34}},
{"box":{"x":1060,"y":690,"width":250,"height":34}}
],"hazards":[],"switches":[],"doors":[]
}
@@ -0,0 +1,8 @@
{
"formatVersion":1,"physicsVersion":1,"number":2,
"copy":{"en":{"title":"A Useful End","hint":"The spikes keep every volunteer. Build a path from what remains."},"ru":{"title":"Полезный конец","hint":"Шипы сохраняют каждого добровольца. Проложите путь из того, что осталось."}},
"world":{"width":2050,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":1830,"y":770,"width":86,"height":110},"targets":{"deaths":3,"seconds":55},
"platforms":[{"box":{"x":0,"y":880,"width":2050,"height":200}},{"box":{"x":1180,"y":740,"width":240,"height":34}}],
"hazards":[{"type":"SPIKES","box":{"x":610,"y":850,"width":260,"height":30}}],"switches":[],"doors":[]
}
@@ -0,0 +1,10 @@
{
"formatVersion":1,"physicsVersion":1,"number":3,
"copy":{"en":{"title":"Dead Weight","hint":"Leave a body on the brass plate so the next volunteer can pass."},"ru":{"title":"Мёртвый груз","hint":"Оставьте тело на латунной плите, чтобы следующий доброволец прошёл."}},
"world":{"width":2050,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":1810,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":50},
"platforms":[{"box":{"x":0,"y":880,"width":2050,"height":200}}],
"hazards":[{"type":"SAW","box":{"x":565,"y":760,"width":92,"height":92},"impulseX":0,"impulseY":-150}],
"switches":[{"type":"PRESSURE","box":{"x":540,"y":860,"width":150,"height":20},"signalId":"gate","durationSeconds":0,"requiredMass":1}],
"doors":[{"box":{"x":1110,"y":570,"width":78,"height":310},"requiredSignals":["gate"]}]
}
@@ -0,0 +1,14 @@
{
"formatVersion":1,"physicsVersion":1,"number":4,
"copy":{"en":{"title":"The Long Lift","hint":"Touch the lever, then ride the moving platform to the upper gallery."},"ru":{"title":"Долгий подъём","hint":"Коснитесь рычага, затем поднимитесь на платформе к верхней галерее."}},
"world":{"width":2200,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":1960,"y":390,"width":86,"height":110},"targets":{"deaths":0,"seconds":60},
"platforms":[
{"box":{"x":0,"y":880,"width":1500,"height":200}},
{"box":{"x":1820,"y":880,"width":380,"height":200}},
{"box":{"x":1660,"y":500,"width":540,"height":34}},
{"box":{"x":1040,"y":790,"width":260,"height":34},"travelX":0,"travelY":-390,"periodSeconds":5.2,"controlId":"lift"}
],
"hazards":[{"type":"PIT","box":{"x":1500,"y":880,"width":320,"height":200}}],
"switches":[{"type":"TOGGLE","box":{"x":520,"y":820,"width":72,"height":60},"signalId":"lift","durationSeconds":0,"requiredMass":1}],"doors":[]
}
@@ -0,0 +1,10 @@
{
"formatVersion":1,"physicsVersion":1,"number":5,
"copy":{"en":{"title":"Express Delivery","hint":"The saw throws to the right. Deliver a volunteer to the distant plate."},"ru":{"title":"Срочная доставка","hint":"Пила бросает вправо. Доставьте добровольца на дальнюю плиту."}},
"world":{"width":2300,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2080,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":65},
"platforms":[{"box":{"x":0,"y":880,"width":2300,"height":200}},{"box":{"x":850,"y":800,"width":330,"height":34}}],
"hazards":[{"type":"SAW","box":{"x":570,"y":760,"width":96,"height":96},"impulseX":500,"impulseY":-700}],
"switches":[{"type":"PRESSURE","box":{"x":930,"y":780,"width":160,"height":20},"signalId":"gate","durationSeconds":0,"requiredMass":1}],
"doors":[{"box":{"x":1390,"y":560,"width":82,"height":320},"requiredSignals":["gate"]}]
}
@@ -0,0 +1,12 @@
{
"formatVersion":1,"physicsVersion":1,"number":6,
"copy":{"en":{"title":"Cold Comfort","hint":"The frost makes a solid block. Use the frozen volunteer as a step."},"ru":{"title":"Холодное утешение","hint":"Мороз создаёт твёрдый блок. Используйте замёрзшего добровольца как ступень."}},
"world":{"width":2150,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":1880,"y":500,"width":86,"height":110},"targets":{"deaths":1,"seconds":65},
"platforms":[
{"box":{"x":0,"y":880,"width":1120,"height":200}},
{"box":{"x":810,"y":590,"width":520,"height":34}},
{"box":{"x":1430,"y":610,"width":570,"height":34}}
],
"hazards":[{"type":"FREEZER","box":{"x":690,"y":776,"width":100,"height":104}}],"switches":[],"doors":[]
}
@@ -0,0 +1,9 @@
{
"formatVersion":1,"physicsVersion":1,"number":7,
"copy":{"en":{"title":"Live Wire","hint":"A fallen volunteer conducts the current and keeps the relay alive."},"ru":{"title":"Живой провод","hint":"Павший доброволец проводит ток и поддерживает реле."}},
"world":{"width":2150,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":1930,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":55},
"platforms":[{"box":{"x":0,"y":880,"width":2150,"height":200}}],
"hazards":[{"type":"ELECTRIC","box":{"x":650,"y":780,"width":110,"height":100},"relayId":"power"}],"switches":[],
"doors":[{"box":{"x":1130,"y":560,"width":84,"height":320},"requiredSignals":["power"]}]
}
@@ -0,0 +1,18 @@
{
"formatVersion":1,"physicsVersion":1,"number":8,
"copy":{"en":{"title":"Borrowed Time","hint":"Jump for the far blue relay if the timed gate closes."},"ru":{"title":"Время взаймы","hint":"Прыгните к дальнему синему реле, если ворота по таймеру закроются."}},
"world":{"width":2450,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2220,"y":600,"width":86,"height":110},"targets":{"deaths":0,"seconds":38},
"platforms":[
{"box":{"x":0,"y":880,"width":1040,"height":200}},
{"box":{"x":1510,"y":880,"width":940,"height":200}},
{"box":{"x":1980,"y":710,"width":470,"height":34}},
{"box":{"x":910,"y":770,"width":320,"height":34},"travelX":380,"travelY":0,"periodSeconds":4.0,"controlId":"clock"}
],
"hazards":[{"type":"PIT","box":{"x":1040,"y":880,"width":470,"height":200}}],
"switches":[
{"type":"TIMED","box":{"x":560,"y":810,"width":90,"height":70},"signalId":"clock","durationSeconds":8.0,"requiredMass":1},
{"type":"TIMED","box":{"x":1600,"y":650,"width":90,"height":70},"signalId":"clock","durationSeconds":3.5,"requiredMass":1}
],
"doors":[{"box":{"x":1770,"y":560,"width":82,"height":320},"requiredSignals":["clock"]}]
}
@@ -0,0 +1,13 @@
{
"formatVersion":1,"physicsVersion":1,"number":9,
"copy":{"en":{"title":"Two Good Reasons","hint":"The final gate needs weight and power at the same time."},"ru":{"title":"Две веские причины","hint":"Последним воротам одновременно нужны вес и питание."}},
"world":{"width":2550,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2310,"y":770,"width":86,"height":110},"targets":{"deaths":2,"seconds":80},
"platforms":[{"box":{"x":0,"y":880,"width":2550,"height":200}}],
"hazards":[
{"type":"SPIKES","box":{"x":520,"y":850,"width":130,"height":30}},
{"type":"ELECTRIC","box":{"x":910,"y":780,"width":110,"height":100},"relayId":"power"}
],
"switches":[{"type":"PRESSURE","box":{"x":510,"y":842,"width":160,"height":38},"signalId":"weight","durationSeconds":0,"requiredMass":1}],
"doors":[{"box":{"x":1510,"y":540,"width":86,"height":340},"requiredSignals":["weight","power"]}]
}
@@ -0,0 +1,20 @@
{
"formatVersion":1,"physicsVersion":1,"number":10,
"copy":{"en":{"title":"The Last Procession","hint":"Launch, freeze, conduct, ascend. Every lesson opens the last relic."},"ru":{"title":"Последнее шествие","hint":"Разгонитесь, замёрзните, проведите ток, поднимитесь. Каждый урок открывает последнюю реликвию."}},
"world":{"width":3100,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2880,"y":330,"width":86,"height":110},"targets":{"deaths":3,"seconds":120},
"platforms":[
{"box":{"x":0,"y":880,"width":1940,"height":200}},
{"box":{"x":2140,"y":880,"width":960,"height":200}},
{"box":{"x":2420,"y":440,"width":680,"height":34}},
{"box":{"x":1800,"y":790,"width":290,"height":34},"travelX":0,"travelY":-410,"periodSeconds":10.0,"controlId":"lift"}
],
"hazards":[
{"type":"SAW","box":{"x":520,"y":760,"width":96,"height":96},"impulseX":700,"impulseY":-240},
{"type":"FREEZER","box":{"x":1110,"y":776,"width":100,"height":104}},
{"type":"ELECTRIC","box":{"x":1510,"y":780,"width":110,"height":100},"relayId":"power"},
{"type":"PIT","box":{"x":1940,"y":880,"width":200,"height":200}}
],
"switches":[{"type":"PRESSURE","box":{"x":860,"y":860,"width":160,"height":20},"signalId":"lift","durationSeconds":0,"requiredMass":1}],
"doors":[{"box":{"x":2310,"y":310,"width":82,"height":570},"requiredSignals":["power"]}]
}
@@ -0,0 +1,82 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public final class LevelContentToolTest {
@Rule public final TemporaryFolder temporary = new TemporaryFolder();
@Test
public void validateCommandLoadsARealDirectoryAndReportsVersions() throws Exception {
Path levels = temporary.newFolder("levels").toPath();
writeFixture(levels);
ByteArrayOutputStream output = new ByteArrayOutputStream();
ByteArrayOutputStream errors = new ByteArrayOutputStream();
int exitCode = LevelContentTool.run(
new String[]{"validate", levels.toString()},
new PrintStream(output, true, StandardCharsets.UTF_8),
new PrintStream(errors, true, StandardCharsets.UTF_8));
assertEquals(0, exitCode);
assertEquals("Validated 1 level(s); format=1; physics=1\n",
output.toString(StandardCharsets.UTF_8));
assertEquals("", errors.toString(StandardCharsets.UTF_8));
}
@Test
public void previewCommandProducesByteIdenticalSvgAndCoordinateExport() throws Exception {
Path levels = temporary.newFolder("preview-levels").toPath();
Path first = temporary.newFolder("preview-first").toPath();
Path second = temporary.newFolder("preview-second").toPath();
writeFixture(levels);
assertEquals(0, LevelContentTool.run(
new String[]{"preview", levels.toString(), first.toString()},
new PrintStream(new ByteArrayOutputStream()),
new PrintStream(new ByteArrayOutputStream())));
assertEquals(0, LevelContentTool.run(
new String[]{"preview", levels.toString(), second.toString()},
new PrintStream(new ByteArrayOutputStream()),
new PrintStream(new ByteArrayOutputStream())));
byte[] firstSvg = Files.readAllBytes(first.resolve("level-01.svg"));
byte[] secondSvg = Files.readAllBytes(second.resolve("level-01.svg"));
assertArrayEquals(firstSvg, secondSvg);
assertArrayEquals(Files.readAllBytes(first.resolve("catalog.tsv")),
Files.readAllBytes(second.resolve("catalog.tsv")));
String svg = new String(firstSvg, StandardCharsets.UTF_8);
assertTrue(svg, svg.contains("viewBox=\"0 0 1920 1080\""));
assertTrue(svg, svg.contains("data-kind=\"spawn\""));
assertTrue(svg, svg.contains("data-kind=\"goal\""));
}
private static void writeFixture(Path levels) throws Exception {
Files.writeString(levels.resolve("index.json"),
"{\"formatVersion\":1,\"physicsVersion\":1,\"levels\":[\"level-01.json\"]}");
Files.writeString(levels.resolve("level-01.json"), validLevelJson());
}
private static String validLevelJson() {
return "{\"formatVersion\":1,\"physicsVersion\":1,\"number\":1,"
+ "\"copy\":{\"en\":{\"title\":\"Test\",\"hint\":\"Go.\"},"
+ "\"ru\":{\"title\":\"Тест\",\"hint\":\"Идите.\"}},"
+ "\"world\":{\"width\":1920,\"height\":1080},"
+ "\"spawn\":{\"x\":130,\"y\":760},"
+ "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110},"
+ "\"targets\":{\"deaths\":0,\"seconds\":30},"
+ "\"platforms\":[{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}],"
+ "\"hazards\":[],\"switches\":[],\"doors\":[]}";
}
}
+1 -1
View File
@@ -15,4 +15,4 @@ dependencyResolutionManagement {
}
rootProject.name = "ProjectSacrifice"
include ':app'
include ':app', ':level-format'