From 5051f316fe6176e7afde7f6a978625f591523fcd Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 22:03:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D1=82=D0=BD?= =?UTF-8?q?=D1=83=D1=8E=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D1=83=D1=80=D1=83=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/issues.jsonl | 4 + README.md | 21 + app/build.gradle | 9 +- .../game/LevelEightPlaythroughTest.java | 9 + .../game/LevelEightScenario.java | 38 +- .../game/BotCommandTrace.java | 195 +++++++++ .../projectsacrifice/game/BotTraceDriver.java | 63 +++ .../projectsacrifice/game/GameStrings.java | 46 +- .../projectsacrifice/game/LevelCatalog.java | 197 --------- .../game/LevelPlaytestBot.java | 43 +- .../game/LevelCatalogLoaderTest.java | 29 ++ .../game/LevelCatalogTest.java | 13 + .../game/LevelJsonLoaderTest.java | 145 +++++++ .../game/LevelPlaytestBotTest.java | 45 ++ docs/CURRENT_ROADMAP.md | 20 +- docs/STAGE4_CONTENT_FORMAT.md | 139 ++++++ docs/STAGE4_QA_REPORT.md | 90 ++++ level-format/build.gradle | 43 ++ .../projectsacrifice/game/LevelCatalog.java | 41 ++ .../game/LevelCatalogLoader.java | 71 +++ .../game/LevelContentTool.java | 162 +++++++ .../game/LevelDefinition.java | 35 ++ .../projectsacrifice/game/LevelFormat.java | 9 + .../game/LevelJsonLoader.java | 408 ++++++++++++++++++ .../game/LevelResourceSource.java | 10 + .../projectsacrifice/game/LevelValidator.java | 118 +++++ .../src/main/resources/levels/index.json | 1 + .../src/main/resources/levels/level-01.json | 11 + .../src/main/resources/levels/level-02.json | 8 + .../src/main/resources/levels/level-03.json | 10 + .../src/main/resources/levels/level-04.json | 14 + .../src/main/resources/levels/level-05.json | 10 + .../src/main/resources/levels/level-06.json | 12 + .../src/main/resources/levels/level-07.json | 9 + .../src/main/resources/levels/level-08.json | 18 + .../src/main/resources/levels/level-09.json | 13 + .../src/main/resources/levels/level-10.json | 20 + .../game/LevelContentToolTest.java | 82 ++++ settings.gradle | 2 +- 39 files changed, 1954 insertions(+), 259 deletions(-) create mode 100644 app/src/main/java/com/projectsacrifice/game/BotCommandTrace.java create mode 100644 app/src/main/java/com/projectsacrifice/game/BotTraceDriver.java delete mode 100644 app/src/main/java/com/projectsacrifice/game/LevelCatalog.java create mode 100644 app/src/test/java/com/projectsacrifice/game/LevelCatalogLoaderTest.java create mode 100644 app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java create mode 100644 docs/STAGE4_CONTENT_FORMAT.md create mode 100644 docs/STAGE4_QA_REPORT.md create mode 100644 level-format/build.gradle create mode 100644 level-format/src/main/java/com/projectsacrifice/game/LevelCatalog.java create mode 100644 level-format/src/main/java/com/projectsacrifice/game/LevelCatalogLoader.java create mode 100644 level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java rename {app => level-format}/src/main/java/com/projectsacrifice/game/LevelDefinition.java (81%) create mode 100644 level-format/src/main/java/com/projectsacrifice/game/LevelFormat.java create mode 100644 level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java create mode 100644 level-format/src/main/java/com/projectsacrifice/game/LevelResourceSource.java create mode 100644 level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java create mode 100644 level-format/src/main/resources/levels/index.json create mode 100644 level-format/src/main/resources/levels/level-01.json create mode 100644 level-format/src/main/resources/levels/level-02.json create mode 100644 level-format/src/main/resources/levels/level-03.json create mode 100644 level-format/src/main/resources/levels/level-04.json create mode 100644 level-format/src/main/resources/levels/level-05.json create mode 100644 level-format/src/main/resources/levels/level-06.json create mode 100644 level-format/src/main/resources/levels/level-07.json create mode 100644 level-format/src/main/resources/levels/level-08.json create mode 100644 level-format/src/main/resources/levels/level-09.json create mode 100644 level-format/src/main/resources/levels/level-10.json create mode 100644 level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4be8d6f..1469c6e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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} diff --git a/README.md b/README.md index 9a20ccf..9b0f814 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 16efb37..4017e9b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightPlaythroughTest.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightPlaythroughTest.java index 3ab6831..5c9d33d 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightPlaythroughTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightPlaythroughTest.java @@ -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 activity) { return driver(activity, 0L); } diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java index 3666368..6373367 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java @@ -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) { diff --git a/app/src/main/java/com/projectsacrifice/game/BotCommandTrace.java b/app/src/main/java/com/projectsacrifice/game/BotCommandTrace.java new file mode 100644 index 0000000..d4d01bc --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/BotCommandTrace.java @@ -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 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); + } + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/BotTraceDriver.java b/app/src/main/java/com/projectsacrifice/game/BotTraceDriver.java new file mode 100644 index 0000000..133c297 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/BotTraceDriver.java @@ -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"); + } + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameStrings.java b/app/src/main/java/com/projectsacrifice/game/GameStrings.java index f380093..a94c4ce 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameStrings.java +++ b/app/src/main/java/com/projectsacrifice/game/GameStrings.java @@ -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); } } diff --git a/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java b/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java deleted file mode 100644 index fa8ce9d..0000000 --- a/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java +++ /dev/null @@ -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 LEVELS = Collections.unmodifiableList(Arrays.asList( - level1(), level2(), level3(), level4(), level5(), - level6(), level7(), level8(), level9(), level10() - )); - - private LevelCatalog() {} - - public static List 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 platforms, - List hazards, - List switches, - List 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 platforms(LevelDefinition.Platform... values) { return Arrays.asList(values); } - @SafeVarargs private static List hazards(LevelDefinition.Hazard... values) { return Arrays.asList(values); } - @SafeVarargs private static List switches(LevelDefinition.Switch... values) { return Arrays.asList(values); } - @SafeVarargs private static List doors(LevelDefinition.Door... values) { return Arrays.asList(values); } -} diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java index dda0763..d3cf0d5 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java @@ -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) { diff --git a/app/src/test/java/com/projectsacrifice/game/LevelCatalogLoaderTest.java b/app/src/test/java/com/projectsacrifice/game/LevelCatalogLoaderTest.java new file mode 100644 index 0000000..f513637 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/LevelCatalogLoaderTest.java @@ -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 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 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); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java index fda3c0d..5934da8 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java @@ -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()) { diff --git a/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java b/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java new file mode 100644 index 0000000..c011289 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java @@ -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\":[]" + + "}"; + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java index 9c6d456..11b40cf 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java @@ -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(); diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index cbb15ed..a84eb91 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -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 нельзя называть принятым внешними тестировщиками. diff --git a/docs/STAGE4_CONTENT_FORMAT.md b/docs/STAGE4_CONTENT_FORMAT.md new file mode 100644 index 0000000..e543279 --- /dev/null +++ b/docs/STAGE4_CONTENT_FORMAT.md @@ -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. + diff --git a/docs/STAGE4_QA_REPORT.md b/docs/STAGE4_QA_REPORT.md new file mode 100644 index 0000000..0dee6b8 --- /dev/null +++ b/docs/STAGE4_QA_REPORT.md @@ -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` остаётся +открытым: требуются две независимые ручные сессии на каждый уровень. diff --git a/level-format/build.gradle b/level-format/build.gradle new file mode 100644 index 0000000..15aeed8 --- /dev/null +++ b/level-format/build.gradle @@ -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]) + } +} diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelCatalog.java b/level-format/src/main/java/com/projectsacrifice/game/LevelCatalog.java new file mode 100644 index 0000000..d22e6ba --- /dev/null +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelCatalog.java @@ -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 LEVELS = loadBundled(); + + private LevelCatalog() {} + + public static List 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 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); + } +} diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelCatalogLoader.java b/level-format/src/main/java/com/projectsacrifice/game/LevelCatalogLoader.java new file mode 100644 index 0000000..f737a57 --- /dev/null +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelCatalogLoader.java @@ -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 load(LevelResourceSource source, String indexPath) { + if (source == null) throw new IllegalArgumentException("level resource source is required"); + String index = requiredPath(indexPath); + List 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 result = new ArrayList<>(); + Set 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); + } +} diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java b/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java new file mode 100644 index 0000000..2bbb200 --- /dev/null +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java @@ -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 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 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 | preview "); + return 2; + } catch (IllegalArgumentException | IOException error) { + errors.println(error.getMessage()); + return 1; + } + } + + private static List loadDirectory(String value) { + Path directory = Paths.get(value).toAbsolutePath().normalize(); + return LevelCatalogLoader.load(new DirectorySource(directory), "index.json"); + } + + private static void writePreview(List 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("\n") + .append("\n") + .append(" ").append(xml(level.number + " — " + level.title)).append("\n") + .append(" \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("\n"); + return value.toString(); + } + + private static void rect(StringBuilder output, String kind, int index, + LevelDefinition.Box box, String color) { + output.append(" \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); + } + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/LevelDefinition.java b/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java similarity index 81% rename from app/src/main/java/com/projectsacrifice/game/LevelDefinition.java rename to level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java index 110a981..d73b165 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelDefinition.java +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java @@ -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 switches, List 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 platforms, + List hazards, + List switches, + List 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; diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelFormat.java b/level-format/src/main/java/com/projectsacrifice/game/LevelFormat.java new file mode 100644 index 0000000..c5f5d85 --- /dev/null +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelFormat.java @@ -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() {} +} diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java b/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java new file mode 100644 index 0000000..3d6272f --- /dev/null +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java @@ -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 ? "" : sourceName; + Map root = object(new JsonValueParser(reader, source).parse(), source); + Map copy = object(required(root, "copy", source), source + ".copy"); + Map english = object(required(copy, "en", source + ".copy"), source + ".copy.en"); + Map russian = object(required(copy, "ru", source + ".copy"), source + ".copy.ru"); + Map world = object(required(root, "world", source), source + ".world"); + Map spawn = object(required(root, "spawn", source), source + ".spawn"); + Map targets = object(required(root, "targets", source), source + ".targets"); + + List platforms = new ArrayList<>(); + List platformValues = array(required(root, "platforms", source), source + ".platforms"); + for (int i = 0; i < platformValues.size(); i++) { + String path = source + ".platforms[" + i + "]"; + Map 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 hazards = hazards(root, source); + List switches = switches(root, source); + List 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 loadIndex(Reader reader, String sourceName) { + String source = sourceName == null ? "" : sourceName; + Map 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 values = array(required(root, "levels", source), source + ".levels"); + if (values.isEmpty()) throw invalid(source + ".levels", "must not be empty"); + List result = new ArrayList<>(); + Set 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 hazards(Map root, String source) { + List values = array(required(root, "hazards", source), source + ".hazards"); + List result = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + String path = source + ".hazards[" + i + "]"; + Map 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 switches(Map root, String source) { + List values = array(required(root, "switches", source), source + ".switches"); + List result = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + String path = source + ".switches[" + i + "]"; + Map 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 doors(Map root, String source) { + List values = array(required(root, "doors", source), source + ".doors"); + List result = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + String path = source + ".doors[" + i + "]"; + Map value = object(values.get(i), path); + List 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 parent, String key, String path) { + List 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 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 value, String key, String path) { + if (!value.containsKey(key)) throw invalid(path + "." + key, "is required"); + return value.get(key); + } + + @SuppressWarnings("unchecked") + private static Map object(Object value, String path) { + if (!(value instanceof Map)) throw invalid(path, "must be an object"); + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List array(Object value, String path) { + if (!(value instanceof List)) throw invalid(path, "must be an array"); + return (List) value; + } + + private static String text(Map 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 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 value, String key, String path) { + return (float) number(required(value, key, path), path + "." + key); + } + + private static float optionalDecimal(Map value, String key, String path, + float fallback) { + return value.containsKey(key) ? (float) number(value.get(key), path + "." + key) : fallback; + } + + private static String optionalText(Map 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 objectValue() { + index++; + Map 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 arrayValue() { + index++; + List 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(); + } + } +} diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelResourceSource.java b/level-format/src/main/java/com/projectsacrifice/game/LevelResourceSource.java new file mode 100644 index 0000000..a9709fd --- /dev/null +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelResourceSource.java @@ -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; +} diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java b/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java new file mode 100644 index 0000000..ff791a1 --- /dev/null +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java @@ -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 ? "" : 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 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); + } +} diff --git a/level-format/src/main/resources/levels/index.json b/level-format/src/main/resources/levels/index.json new file mode 100644 index 0000000..5a10188 --- /dev/null +++ b/level-format/src/main/resources/levels/index.json @@ -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"]} diff --git a/level-format/src/main/resources/levels/level-01.json b/level-format/src/main/resources/levels/level-01.json new file mode 100644 index 0000000..5d7d4ef --- /dev/null +++ b/level-format/src/main/resources/levels/level-01.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":[] +} diff --git a/level-format/src/main/resources/levels/level-02.json b/level-format/src/main/resources/levels/level-02.json new file mode 100644 index 0000000..75b33e2 --- /dev/null +++ b/level-format/src/main/resources/levels/level-02.json @@ -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":[] +} diff --git a/level-format/src/main/resources/levels/level-03.json b/level-format/src/main/resources/levels/level-03.json new file mode 100644 index 0000000..f47b8bc --- /dev/null +++ b/level-format/src/main/resources/levels/level-03.json @@ -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"]}] +} diff --git a/level-format/src/main/resources/levels/level-04.json b/level-format/src/main/resources/levels/level-04.json new file mode 100644 index 0000000..edb946a --- /dev/null +++ b/level-format/src/main/resources/levels/level-04.json @@ -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":[] +} diff --git a/level-format/src/main/resources/levels/level-05.json b/level-format/src/main/resources/levels/level-05.json new file mode 100644 index 0000000..5c4939b --- /dev/null +++ b/level-format/src/main/resources/levels/level-05.json @@ -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"]}] +} diff --git a/level-format/src/main/resources/levels/level-06.json b/level-format/src/main/resources/levels/level-06.json new file mode 100644 index 0000000..d3a819f --- /dev/null +++ b/level-format/src/main/resources/levels/level-06.json @@ -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":[] +} diff --git a/level-format/src/main/resources/levels/level-07.json b/level-format/src/main/resources/levels/level-07.json new file mode 100644 index 0000000..dd5a074 --- /dev/null +++ b/level-format/src/main/resources/levels/level-07.json @@ -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"]}] +} diff --git a/level-format/src/main/resources/levels/level-08.json b/level-format/src/main/resources/levels/level-08.json new file mode 100644 index 0000000..00c39e5 --- /dev/null +++ b/level-format/src/main/resources/levels/level-08.json @@ -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"]}] +} diff --git a/level-format/src/main/resources/levels/level-09.json b/level-format/src/main/resources/levels/level-09.json new file mode 100644 index 0000000..d0815b9 --- /dev/null +++ b/level-format/src/main/resources/levels/level-09.json @@ -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"]}] +} diff --git a/level-format/src/main/resources/levels/level-10.json b/level-format/src/main/resources/levels/level-10.json new file mode 100644 index 0000000..a73a49b --- /dev/null +++ b/level-format/src/main/resources/levels/level-10.json @@ -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"]}] +} diff --git a/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java b/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java new file mode 100644 index 0000000..8fbe391 --- /dev/null +++ b/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java @@ -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\":[]}"; + } +} diff --git a/settings.gradle b/settings.gradle index ea7302b..e285547 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,4 +15,4 @@ dependencyResolutionManagement { } rootProject.name = "ProjectSacrifice" -include ':app' +include ':app', ':level-format'