Завершить контентную архитектуру этапа 4
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 нельзя называть принятым внешними тестировщиками.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` остаётся
|
||||
открытым: требуются две независимые ручные сессии на каждый уровень.
|
||||
@@ -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("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace("\"", """);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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
@@ -15,4 +15,4 @@ dependencyResolutionManagement {
|
||||
}
|
||||
|
||||
rootProject.name = "ProjectSacrifice"
|
||||
include ':app'
|
||||
include ':app', ':level-format'
|
||||
|
||||
Reference in New Issue
Block a user