From b31c2ea720015a263643cea9693bad6080e855a6 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Wed, 19 Aug 2026 10:51:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B2=D1=83=D1=8E=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D1=83=20=D0=BC=D0=B8=D1=80=D0=B0=20Foundations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/issues.jsonl | 2 +- README.md | 13 +- .../game/FoundationsPlaythroughTest.java | 84 +++++++++++++ .../game/FoundationsRestartTest.java | 96 ++++++++++++++ .../game/LevelPaginationTest.java | 21 +++- .../game/FoundationsScenarios.java | 119 ++++++++++++++++++ .../game/FoundationsContentTest.java | 56 +++++++++ .../game/LevelCatalogTest.java | 2 +- docs/CURRENT_ROADMAP.md | 5 + docs/LEVEL_SOLUTION_GRAPHS.md | 73 +++++++++++ docs/STAGE6_CONTENT_MATRIX.md | 18 +-- docs/STAGE6_FOUNDATIONS_WAVE1.md | 56 +++++++++ .../src/main/resources/levels/index.json | 2 +- .../src/main/resources/levels/level-19.json | 14 +++ .../src/main/resources/levels/level-20.json | 18 +++ .../src/main/resources/levels/level-21.json | 13 ++ .../src/main/resources/levels/level-22.json | 17 +++ .../game/LevelContentToolTest.java | 2 +- 18 files changed, 592 insertions(+), 19 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/FoundationsPlaythroughTest.java create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/FoundationsRestartTest.java create mode 100644 app/src/main/java/com/projectsacrifice/game/FoundationsScenarios.java create mode 100644 app/src/test/java/com/projectsacrifice/game/FoundationsContentTest.java create mode 100644 docs/STAGE6_FOUNDATIONS_WAVE1.md create mode 100644 level-format/src/main/resources/levels/level-19.json create mode 100644 level-format/src/main/resources/levels/level-20.json create mode 100644 level-format/src/main/resources/levels/level-21.json create mode 100644 level-format/src/main/resources/levels/level-22.json diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 01c588e..ec3d136 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -67,7 +67,7 @@ {"_type":"issue","id":"goon-game-analytics-contract","title":"Схема gameplay analytics","description":"Версионировать level_start/restart/death/complete/abandon/challenge и heatmap payload.","acceptance_criteria":"События документированы, минимальны и проверены на тестовом backend.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:36Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:35Z","metadata":{"stages":7,"primary_stage":7},"labels":["analytics","primary-stage-7","production","stage-7"],"dependencies":[{"issue_id":"goon-game-analytics-contract","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-modular-characters","title":"Модульные персонажи и косметика","description":"Body/helmet/face/weapon/cape/accessory, случайные имена и 30+ косметических элементов.","acceptance_criteria":"Новые жертвы визуально различаются, комбинации не ломают анимации.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2880,"created_at":"2026-08-17T21:40:21Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["art","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-modular-characters","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-challenges","title":"Challenges, collectibles и статистика","description":"Реализовать casualty/time/collectible цели и отображение трёх звёзд.","acceptance_criteria":"Расчёт покрыт unit-тестами и сохраняется по LevelID.","notes":"2026-08-19: claimed after weighted-object slice 259fcd3 passed 108 app JVM + 2 content-tool JVM and exact Android 48/48. TDD scope: stable LevelID/WorldID progression keys with legacy numeric migration, casualty/time/collectible challenge evaluation, persistent best three-star record, data-driven optional collectible, level-select/result rendering, automation and one teaching route without weakening existing gates.\nFinal post-review gate: duplicate stable LevelID now fails catalog load before progress-key collision (targeted RED then GREEN). Clean 116 app JVM + 2 content-tool JVM; final cold host-GPU AVD exact Android 52/52 in 551.773s. Verified APK SHA-256 0b70a0a1a508425c311d7de473318e3bb83407f5f27e9dd4a8e1876b2eb25344.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T06:24:46Z","started_at":"2026-08-19T04:41:26Z","closed_at":"2026-08-19T05:58:26Z","close_reason":"Accepted stable LevelID/WorldID challenge progression: progress v3/session v5 migrations, casualty/time/collectible stars, level 18 3/3 route, 115+2 JVM and exact Android 52/52 (553.686s), APK SHA-256 9c12cfbb15e313421d58f891d9d107af14c9e8e626228fa8fd0c74e56c293d14.","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","progression","stage-6"],"dependencies":[{"issue_id":"goon-game-challenges","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","notes":"2026-08-19: claimed after challenge/progression commit 3de8d45 passed 116 app JVM + 2 content-tool JVM and cold host-GPU exact Android 52/52. Executable production matrix now fixes release at 69 levels: workshop 1-17 plus Foundations 18-30, Physics 31-43, States 44-56, Mastery 57-69; each world has 11 MAIN + 1 CHALLENGE + 1 FINALE. TDD REDs captured missing matrix contract and missing validate-matrix CLI; GREEN has 7/7 level-format + 116/116 app JVM, both validators, lintDebug, app/test APK and ZIP integrity. Android preBuild fail-closes matrix rules and authored-prefix drift; authored production prefix is Level 18 only. Next wave is Foundations 19-30 through graph/JSON/bot/DoD; issue remains in_progress.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T06:39:18Z","started_at":"2026-08-19T06:26:20Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","notes":"2026-08-19: executable matrix commit 4869649 fixed release at 69 levels: workshop 1-17 plus four 13-level production worlds (52 rows). Foundations wave 1 now authors Levels 19-22; matrix prefix is 5/52 including Level 18. TDD/device REDs fixed only in new content: inaccessible L19 cache, skipped second L20 body, late L21 saw takeoff, early-moving L22 ferry; pagination acceptance updated from 2 to 3 pages. Four route methods passed 12/12 playthroughs, restart DoD passed 1/1. Clean gate: 117/117 app JVM, 7/7 level-format, validators 22/authored5, lint/app/test APK; final cold unfiltered Android 57/57 in 1807.264s, APK SHA 81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef. Next bounded slice is Foundations 23-26; issue remains in_progress until Level 69.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T07:50:26Z","started_at":"2026-08-19T06:26:20Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","notes":"2026-08-19: claimed after corpse-state slice 19bb64e passed 102 JVM and exact Android 46/46. TDD scope: public IWeightedObject mass contract, JSON Box/Rock/Ball/IceBlock/Counterweight definitions, deterministic runtime/save/render/automation, pressure parity and one teaching level with solution bot.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T04:38:25Z","started_at":"2026-08-19T03:35:44Z","closed_at":"2026-08-19T04:38:25Z","close_reason":"Shared pressure-mass contract, five authored physical-object types, deterministic runtime/save v4/render/export, level 17 and production-input route accepted. Clean 108 app JVM + 2 format JVM; focused 8/8; exact unfiltered Android 16 matrix 48/48 in 2223.695s.","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","notes":"2026-08-19: claimed after the three Stage 6 foundation slices. Will proceed test-first per hazard with data definition, deterministic behavior, teaching content, solution graph and production-input route; crusher/fire precede the dependent crushed/burned corpse states.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T02:28:37Z","started_at":"2026-08-19T01:07:11Z","closed_at":"2026-08-19T02:28:37Z","close_reason":"Crusher, fire, projectile and cyclic explosion are data-driven, validated and shipped with teaching levels 11-14, solution graphs and 3x production-input routes; clean 99 JVM/lint/APK and unfiltered Android 43/43 green.","metadata":{"stages":6,"primary_stage":6},"labels":["hazards","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-hazards-full","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:10Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","notes":"2026-08-19: claimed after production hazards dc2ebcb passed 99 JVM and Android 43/43. Will add explicit crushed/burned/shattered and derived corpse behavior test-first, then one teaching route/solution graph per new gameplay-relevant state and full regression.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:49Z","started_at":"2026-08-19T02:31:25Z","closed_at":"2026-08-19T03:32:49Z","close_reason":"GDD corpse states complete: existing normal/pinned/frozen/conductive routes plus new burned/crushed contracts, save v3 migration, levels 15-16 and bots. Clean 102 JVM; exact Android 46/46 in 1842.248s. IsDestroyed is a lifecycle field; Shattered is not an approved GDD state.","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-hazards-full","type":"blocks","created_at":"2026-08-19T04:07:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index d0d279b..900677d 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ Burned/crushed corpse states are taught in levels 15–16, level 17 adds five authored physical-object types over one shared pressure-mass contract, and level 18 teaches the casualty/time/cache challenge contract with persistent three-star progress keyed by stable `levelId`. +The first bounded Foundations authoring wave adds levels 19–22: a two-jump +movement route, a two-body spike bridge, a retained pressure-door body and a +toggle-controlled horizontal ferry. All four routes pass three consecutive runs, +and restart clears the stateful bridge/ferry setups. Scope and final evidence are in `docs/STAGE6_CHARACTER_STATES.md`, `docs/STAGE6_CORPSE_MANAGER.md`, and `docs/STAGE6_PUZZLE_SYSTEM.md`; the latest hazard evidence is in `docs/STAGE6_PRODUCTION_HAZARDS.md`, and the burned/crushed @@ -133,10 +137,11 @@ contract is in `docs/STAGE6_CORPSE_STATES.md`. Weighted-object evidence is in `docs/STAGE6_CHALLENGES.md`. The executable content matrix fixes the release at 69 levels: 17 accepted workshop levels plus 52 production slots in Foundations, Physics, States and Mastery. Its contract and Level DoD pipeline are documented -in `docs/STAGE6_CONTENT_MATRIX.md`. The latest exact gate is `116 app JVM + 2 -content-tool JVM / 52 Android` on the eighteen-level candidate (`OK (52 tests)`, -551.773 seconds). The verified debug APK SHA-256 is -`0b70a0a1a508425c311d7de473318e3bb83407f5f27e9dd4a8e1876b2eb25344`. +in `docs/STAGE6_CONTENT_MATRIX.md`; wave evidence is in +`docs/STAGE6_FOUNDATIONS_WAVE1.md`. The latest exact gate is `117 app JVM + 7 +content-tool JVM / 57 Android` on the twenty-two-level candidate (`57/57`, +1807.264 seconds). The verified debug APK SHA-256 is +`81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef`. 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: diff --git a/app/src/androidTest/java/com/projectsacrifice/game/FoundationsPlaythroughTest.java b/app/src/androidTest/java/com/projectsacrifice/game/FoundationsPlaythroughTest.java new file mode 100644 index 0000000..d42f067 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/FoundationsPlaythroughTest.java @@ -0,0 +1,84 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.app.Instrumentation; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class FoundationsPlaythroughTest { + @Test public void firstReturnCompletesDeathlessThreeTimes() { + runThree(FoundationsScenarios.levelNineteen(), 0, 0); + } + + @Test public void coveredPathUsesExactlyTwoBodiesThreeTimes() { + runThree(FoundationsScenarios.levelTwenty(), 2, 2); + } + + @Test public void heldOpenUsesExactlyOnePlateBodyThreeTimes() { + runThree(FoundationsScenarios.levelTwentyOne(), 1, 1); + } + + @Test public void clockworkStepCompletesDeathlessThreeTimes() { + runThree(FoundationsScenarios.levelTwentyTwo(), 0, 0); + } + + private static void runThree(LevelPlaytestBot.Scenario scenario, + int expectedDeaths, int expectedCorpses) { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + Driver driver = Driver.from(activity); + for (int run = 1; run <= 3; run++) { + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 30_000L); + assertTrue("run=" + run + "; " + result.diagnostic(), result.completed()); + assertEquals("run=" + run, expectedDeaths, result.deaths()); + assertEquals("run=" + run, expectedCorpses, result.corpseCount()); + assertTrue("run=" + run + "; collectible", driver.snapshot().collectibleFound()); + } + } + } + + private static final class Driver implements LevelPlaytestBot.Driver { + private final Instrumentation instrumentation; + private final GameView view; + + private Driver(Instrumentation instrumentation, GameView view) { + this.instrumentation = instrumentation; + this.view = view; + } + + static Driver from(ActivityScenario activity) { + GameView[] target = new GameView[1]; + activity.onActivity(current -> target[0] = current.gameViewForAutomation()); + return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]); + } + + @Override public void startLevel(int oneBasedLevelNumber) { + instrumentation.runOnMainSync(() -> { + view.enableDeterministicAutomationClock(); + view.startLevelForAutomation(oneBasedLevelNumber); + }); + } + + @Override public void setInput(boolean left, boolean right, boolean jump) { + instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump)); + } + + @Override public GameView.AutomationSnapshot snapshot() { + GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1]; + instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot()); + return value[0]; + } + + @Override public void waitForNextDecision(long millis) { + instrumentation.runOnMainSync(() -> + view.advanceDeterministicAutomation(Math.max(16L, millis))); + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/FoundationsRestartTest.java b/app/src/androidTest/java/com/projectsacrifice/game/FoundationsRestartTest.java new file mode 100644 index 0000000..9c3dd72 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/FoundationsRestartTest.java @@ -0,0 +1,96 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.app.Instrumentation; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class FoundationsRestartTest { + @Test + public void restartClearsCoveredPathBodiesAndClockworkFerryState() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + GameView[] target = new GameView[1]; + activity.onActivity(current -> target[0] = current.gameViewForAutomation()); + GameView view = target[0]; + + start(instrumentation, view, 20); + input(instrumentation, view, false, true, false); + GameView.AutomationSnapshot bridge = advanceUntil(instrumentation, view, + snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1); + assertEquals(1, bridge.deaths()); + instrumentation.runOnMainSync(() -> { + view.setAutomationInput(false, true, true); + view.restartLevelForAutomation(); + }); + assertCleanRestart(snapshot(instrumentation, view), 20); + + start(instrumentation, view, 22); + input(instrumentation, view, false, true, false); + GameView.AutomationSnapshot ferry = advanceUntil(instrumentation, view, + GameView.AutomationSnapshot::firstMovingPlatformPowered); + assertTrue(ferry.firstMovingPlatformPowered()); + instrumentation.runOnMainSync(() -> { + view.setAutomationInput(false, true, true); + view.restartLevelForAutomation(); + }); + GameView.AutomationSnapshot restartedFerry = snapshot(instrumentation, view); + assertCleanRestart(restartedFerry, 22); + assertFalse(restartedFerry.firstMovingPlatformPowered()); + assertEquals(940f, restartedFerry.firstMovingPlatformX(), 0.5f); + } + } + + private static void assertCleanRestart(GameView.AutomationSnapshot snapshot, int level) { + assertEquals(level, snapshot.levelNumber()); + assertTrue(snapshot.playerAlive()); + assertEquals(0, snapshot.deaths()); + assertEquals(0, snapshot.corpseCount()); + assertFalse(snapshot.collectibleFound()); + assertFalse(snapshot.inputActive()); + assertTrue(snapshot.elapsedMilliseconds() < 100L); + assertTrue(snapshot.playerX() >= 125f && snapshot.playerX() <= 135f); + } + + private static void start(Instrumentation instrumentation, GameView view, int level) { + instrumentation.runOnMainSync(() -> { + view.enableDeterministicAutomationClock(); + view.startLevelForAutomation(level); + }); + } + + private static void input(Instrumentation instrumentation, GameView view, + boolean left, boolean right, boolean jump) { + instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump)); + } + + private static GameView.AutomationSnapshot advanceUntil( + Instrumentation instrumentation, GameView view, + LevelPlaytestBot.SnapshotCondition condition) { + GameView.AutomationSnapshot current = snapshot(instrumentation, view); + for (int tick = 0; tick < 100; tick++) { + if (condition.reached(current)) return current; + instrumentation.runOnMainSync(() -> view.advanceDeterministicAutomation(96L)); + current = snapshot(instrumentation, view); + } + throw new AssertionError("Foundations restart setup timed out; level=" + + current.levelNumber() + ", x=" + current.playerX() + + ", deaths=" + current.deaths() + ", corpses=" + current.corpseCount()); + } + + private static GameView.AutomationSnapshot snapshot( + Instrumentation instrumentation, GameView view) { + GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1]; + instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot()); + return value[0]; + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java index 35e0043..08be3be 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java @@ -24,7 +24,7 @@ public final class LevelPaginationTest { GameView view = current.gameViewForAutomation(); view.setLanguageForAutomation(GameLanguage.EN); assertEquals(0, view.levelPageIndexForAutomation()); - assertEquals(2, view.levelPageCountForAutomation()); + assertEquals(3, view.levelPageCountForAutomation()); assertTrue(view.visibleLocalizedTextForAutomation().contains("The Volunteer")); assertFalse(view.visibleLocalizedTextForAutomation().contains("Steam Charge")); }); @@ -39,10 +39,27 @@ public final class LevelPaginationTest { assertTrue(visible.contains("Ashen Counterweight")); assertTrue(visible.contains("The Whole Weight")); assertTrue(visible.contains("Threefold Proof")); + assertTrue(visible.contains("First Return")); + assertTrue(visible.contains("Covered Path")); + assertFalse(visible.contains("Held Open")); assertFalse(visible.contains("The Volunteer")); - assertTrue(visible.contains("Page 2 of 2")); + assertTrue(visible.contains("Page 2 of 3")); }); + tap(activity, 1_730f, 965f); + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + List visible = view.visibleLocalizedTextForAutomation(); + assertEquals(2, view.levelPageIndexForAutomation()); + assertTrue(visible.contains("Held Open")); + assertTrue(visible.contains("Clockwork Step")); + assertFalse(visible.contains("Covered Path")); + assertTrue(visible.contains("Page 3 of 3")); + }); + + tap(activity, 190f, 965f); + activity.onActivity(current -> + assertEquals(1, current.gameViewForAutomation().levelPageIndexForAutomation())); tap(activity, 190f, 965f); activity.onActivity(current -> assertEquals(0, current.gameViewForAutomation().levelPageIndexForAutomation())); diff --git a/app/src/main/java/com/projectsacrifice/game/FoundationsScenarios.java b/app/src/main/java/com/projectsacrifice/game/FoundationsScenarios.java new file mode 100644 index 0000000..b9530af --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/FoundationsScenarios.java @@ -0,0 +1,119 @@ +package com.projectsacrifice.game; + +/** Production-input intended routes for the first authored Foundations wave. */ +public final class FoundationsScenarios { + private FoundationsScenarios() {} + + public static LevelPlaytestBot.Scenario levelNineteen() { + return LevelPlaytestBot.Scenario.builder(19, "Level 19 - first return") + .step("Approach the lower ledge", 2_500L, LevelPlaytestBot.Input.right(), + noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(500f))) + .step("Clear the lower ledge", 2_500L, LevelPlaytestBot.Input.rightJump(), + noDeaths(snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 920f)) + .step("Release before the high ledge", 1_500L, LevelPlaytestBot.Input.right(), + noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(980f))) + .step("Jump onto the high ledge", 2_500L, LevelPlaytestBot.Input.rightJump(), + noDeaths(snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_400f)) + .step("Collect the cache after both jumps", 1_500L, LevelPlaytestBot.Input.right(), + noDeaths(snapshot -> snapshot.playerAlive() && snapshot.collectibleFound())) + .step("Land beyond the high ledge", 2_500L, LevelPlaytestBot.Input.right(), + noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_450f))) + .step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(), + noDeaths(GameView.AutomationSnapshot::completed)) + .build(); + } + + public static LevelPlaytestBot.Scenario levelTwenty() { + return LevelPlaytestBot.Scenario.builder(20, "Level 20 - covered path") + .step("Place the near bridge body", 3_500L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(2, snapshot -> snapshot.deaths() == 1)) + .step("Wait for the second volunteer", 1_500L, LevelPlaytestBot.Input.none(), + LevelPlaytestBot.whileDeathsAtMost(2, FoundationsScenarios::respawned)) + .step("Approach the first bridge body", 2_500L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(2, + LevelPlaytestBot.groundedAliveAtOrBeyond(520f))) + .step("Place the far bridge body", 3_000L, LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(2, snapshot -> snapshot.deaths() == 2)) + .step("Wait for the crossing volunteer", 1_500L, LevelPlaytestBot.Input.none(), + LevelPlaytestBot.whileDeathsAtMost(2, FoundationsScenarios::respawned)) + .step("Return to the covered path", 2_500L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(2, + LevelPlaytestBot.groundedAliveAtOrBeyond(520f))) + .step("Cross both retained bodies", 3_500L, LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(2, + LevelPlaytestBot.groundedAliveAtOrBeyond(900f))) + .step("Approach the cache ledge", 2_000L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(2, + LevelPlaytestBot.groundedAliveAtOrBeyond(980f))) + .step("Collect the cache", 2_500L, LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(2, + snapshot -> snapshot.playerAlive() && snapshot.collectibleFound())) + .step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(2, + GameView.AutomationSnapshot::completed)) + .build(); + } + + public static LevelPlaytestBot.Scenario levelTwentyOne() { + return LevelPlaytestBot.Scenario.builder(21, "Level 21 - held open") + .step("Leave one volunteer on the plate", 4_000L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.deaths() == 1)) + .step("Prove the body holds the door", 2_000L, LevelPlaytestBot.Input.none(), + LevelPlaytestBot.whileDeathsAtMost(1, + GameView.AutomationSnapshot::allDoorsOpen)) + .step("Wait for the next volunteer", 1_500L, LevelPlaytestBot.Input.none(), + LevelPlaytestBot.whileDeathsAtMost(1, FoundationsScenarios::respawned)) + .step("Stage before the saw", 2_500L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + LevelPlaytestBot.groundedAliveAtOrBeyond(470f))) + .step("Take off before the saw", 1_500L, LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() && snapshot.playerY() <= 620f)) + .step("Clear the saw and retained body", 2_500L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 820f)) + .step("Stage before the open door", 2_000L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + LevelPlaytestBot.groundedAliveAtOrBeyond(1_020f))) + .step("Jump through the cache", 3_000L, LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive() + && snapshot.collectibleFound() && snapshot.allDoorsOpen())) + .step("Reach the relic through the held door", 4_000L, + LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + GameView.AutomationSnapshot::completed)) + .build(); + } + + public static LevelPlaytestBot.Scenario levelTwentyTwo() { + return LevelPlaytestBot.Scenario.builder(22, "Level 22 - clockwork step") + .step("Set the ferry toggle", 2_500L, LevelPlaytestBot.Input.right(), + noDeaths(GameView.AutomationSnapshot::firstMovingPlatformPowered)) + .step("Approach the ferry", 2_500L, LevelPlaytestBot.Input.right(), + noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(850f))) + .step("Board the moving ferry", 2_000L, LevelPlaytestBot.Input.rightJump(), + noDeaths(snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerOnMovingPlatform())) + .step("Ride to the far overlap and collect the cache", 5_500L, + LevelPlaytestBot.Input.none(), + noDeaths(snapshot -> snapshot.playerAlive() + && snapshot.playerOnMovingPlatform() + && snapshot.firstMovingPlatformX() >= 1_280f + && snapshot.collectibleFound())) + .step("Step onto the far floor", 2_500L, LevelPlaytestBot.Input.right(), + noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_650f))) + .step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(), + noDeaths(GameView.AutomationSnapshot::completed)) + .build(); + } + + private static LevelPlaytestBot.SnapshotCondition noDeaths( + LevelPlaytestBot.SnapshotCondition condition) { + return LevelPlaytestBot.whileDeathsAtMost(0, condition); + } + + private static boolean respawned(GameView.AutomationSnapshot snapshot) { + return snapshot.playerAlive() && snapshot.playerX() < 250f; + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/FoundationsContentTest.java b/app/src/test/java/com/projectsacrifice/game/FoundationsContentTest.java new file mode 100644 index 0000000..89b73f9 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/FoundationsContentTest.java @@ -0,0 +1,56 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; + +public final class FoundationsContentTest { + @Test + public void firstFoundationsWaveMatchesTheApprovedMatrixAndTeachesOneIdeaAtATime() { + List levels = LevelCatalog.all(); + assertEquals(22, levels.size()); + + LevelDefinition firstReturn = levels.get(18); + assertIdentity(firstReturn, 19, "foundations.first-return", "First Return", 0, 28); + assertTrue(firstReturn.hazards.isEmpty()); + assertTrue(firstReturn.switches.isEmpty()); + assertTrue(firstReturn.doors.isEmpty()); + + LevelDefinition coveredPath = levels.get(19); + assertIdentity(coveredPath, 20, "foundations.covered-path", "Covered Path", 2, 42); + assertTrue(coveredPath.containsMechanic(LevelDefinition.HazardType.SPIKES)); + assertTrue(coveredPath.switches.isEmpty()); + + LevelDefinition heldOpen = levels.get(20); + assertIdentity(heldOpen, 21, "foundations.held-open", "Held Open", 1, 45); + assertTrue(heldOpen.containsMechanic(LevelDefinition.HazardType.SAW)); + assertTrue(heldOpen.containsMechanic(LevelDefinition.SwitchType.PRESSURE)); + assertFalse(heldOpen.doors.isEmpty()); + assertEquals(heldOpen.switches.get(0).signalId, + heldOpen.doors.get(0).requiredSignals.get(0)); + + LevelDefinition clockwork = levels.get(21); + assertIdentity(clockwork, 22, "foundations.clockwork-step", "Clockwork Step", 0, 48); + assertTrue(clockwork.containsMechanic(LevelDefinition.SwitchType.TOGGLE)); + assertTrue(clockwork.containsMechanic(LevelDefinition.HazardType.PIT)); + assertTrue(clockwork.hasMovingPlatform()); + assertFalse(clockwork.platforms.get(2).controlId.isEmpty()); + assertEquals(clockwork.switches.get(0).signalId, clockwork.platforms.get(2).controlId); + } + + private static void assertIdentity(LevelDefinition level, int number, String id, String title, + int targetDeaths, int targetSeconds) { + assertEquals(number, level.number); + assertEquals(id, level.levelId); + assertEquals("foundations", level.worldId); + assertEquals(title, level.title); + assertEquals(targetDeaths, level.targetDeaths); + assertEquals(targetSeconds, level.targetSeconds); + assertNotNull(level.collectible); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java index f8ba0b9..8da2ca4 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java @@ -15,7 +15,7 @@ public final class LevelCatalogTest { @Test public void productionCatalogContainsMechanicsAndChallengeTeachingLevels() { List levels = LevelCatalog.all(); - assertEquals(18, levels.size()); + assertEquals(22, levels.size()); Set titles = new HashSet<>(); for (int i = 0; i < levels.size(); i++) { LevelDefinition level = levels.get(i); diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index 7706858..8fb6198 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -213,6 +213,11 @@ Android matrix `52/52` (`551.773 s`). Evidence — `STAGE6_CHALLENGES.md`. Каждый мир содержит 11 MAIN, один CHALLENGE и один FINALE; Gradle `preBuild` проверяет матрицу и соответствие авторизованного JSON-префикса. Контракт — `STAGE6_CONTENT_MATRIX.md`; следующий authoring wave — Foundations 19–30. +Первый bounded wave Levels 19–22 принят: movement/jump, два тела над раздельными +spikes, retained pressure Door и toggle ferry имеют solution graphs, collectibles, +restart proof и 12/12 production-input playthroughs. Cold unfiltered candidate +прошёл Android `57/57` за `1807.264 s`, app JVM `117/117`, content-tool JVM `7/7`. +Evidence — `STAGE6_FOUNDATIONS_WAVE1.md`; следующий slice — Levels 23–26. ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней diff --git a/docs/LEVEL_SOLUTION_GRAPHS.md b/docs/LEVEL_SOLUTION_GRAPHS.md index 2fd2e81..59c8943 100644 --- a/docs/LEVEL_SOLUTION_GRAPHS.md +++ b/docs/LEVEL_SOLUTION_GRAPHS.md @@ -462,6 +462,79 @@ time runs. Legacy levels без collectible получают третью зве `bestStars=3`; новый `ProgressStore` после трёх запусков возвращает 3 звезды для `foundations.threefold-proof`. +## Уровень 19 — `First Return` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `0`. + +```text +LevelID="foundations.first-return", WorldID="foundations" +→ игрок подходит к нижнему уступу x=650..890 и перепрыгивает его +→ отпускает jump, подтверждает новое касание пола и создаёт второй jump edge +→ вторым прыжком проходит высокий уступ x=1080..1380 +→ на безопасной дальней прямой забирает collectible x=1500 +→ достигает goal x=2090..2176 без смертей +``` + +**Контракт:** уровень повторно калибрует движение и раздельные jump edges перед +production-комбинациями. На нём нет hazard, switch или Door; failure означает +ошибку управления, а не скрытое состояние головоломки. + +## Уровень 20 — `Covered Path` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `2`; оба тела сохраняются. + +```text +LevelID="foundations.covered-path", WorldID="foundations" +→ первый доброволец идёт в ближние spikes x=700..800 и оставляет pinned body +→ второй прыгает с x≈520 через первое тело и погибает на spikes x=930..1030 +→ третий возвращается и одним прыжком пересекает две раздельные опоры +→ отдельным jump edge входит на ledge x=1240..1520 и забирает collectible +→ достигает goal с deaths=2, corpses=2 +``` + +**Контракт:** две раздельные spike-зоны делают обе жертвы наблюдаемыми и не +позволяют одним ранним телом случайно закрыть весь hazard. Bot fail-fast отклоняет +третью смерть; три последовательных запуска доказывают очистку старых тел. + +## Уровень 21 — `Held Open` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `1`; Door остаётся открыта телом. + +```text +LevelID="foundations.held-open", WorldID="foundations" +→ первый доброволец достигает saw x=650..742 +→ тело оседает на pressure plate x=625..785 и активирует held-gate +→ следующий доброволец подтверждает allDoorsOpen=true +→ начинает прыжок из рассчитанного окна x≈470 и очищает saw + retained body +→ прыгает через collectible x=1160 до Door x=1260 +→ проходит удерживаемую Door и достигает goal с deaths=1, corpses=1 +``` + +**Контракт:** plate и Door используют один signal ID `held-gate`. Коллекционный +маршрут лежит внутри того же доказанного прыжка и не требует второй жертвы. + +## Уровень 22 — `Clockwork Step` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `0`. + +```text +LevelID="foundations.clockwork-step", WorldID="foundations" +→ игрок один раз касается toggle ferry x=760..832 +→ подходит к launch overlap и прыгает на platform x=940..1260 +→ snapshot подтверждает playerOnGround && playerOnMovingPlatform +→ без input едет до platformX≥1280 и по пути забирает collectible x=1435 +→ сходит на дальний floor x=1600..2500, не касаясь PIT x=1100..1600 +→ достигает goal без смерти +``` + +**Контракт:** toggle и moving platform разделяют signal `ferry`. Платформа имеет +160 px launch overlap до движения и 20 px far overlap в конечной фазе; bot ждёт +реальную поддержку, а не единичное пересечение в воздухе. + ## Проверки, следующие из графов 1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8. diff --git a/docs/STAGE6_CONTENT_MATRIX.md b/docs/STAGE6_CONTENT_MATRIX.md index a606e28..ca0028d 100644 --- a/docs/STAGE6_CONTENT_MATRIX.md +++ b/docs/STAGE6_CONTENT_MATRIX.md @@ -49,7 +49,7 @@ finale не вводят новую механику: они проверяют Android `preBuild` зависит от этого gate. Текущее ожидаемое сообщение: ```text -Validated production content matrix: worlds=4; planned=52; authored=1; releaseTotal=69 +Validated production content matrix: worlds=4; planned=52; authored=5; releaseTotal=69 ``` ## Content pipeline и Level DoD @@ -75,18 +75,18 @@ errors), затем отсутствующую CLI-команду `validate-matr добавлен отрицательный regression: изменение planned title Level 18 отклоняется как drift реального JSON-префикса. -Чистый gate на 19 августа 2026 года: +Текущий gate после первого Foundations wave на 19 августа 2026 года: - `ProductionContentMatrixTest` + `LevelContentToolTest`: `7/7`; -- app JVM regression: `116/116`; -- `validateLevels`: `18` authored levels; -- `validateContentMatrix`: `4` worlds, `52` planned, `1` authored production row, +- app JVM regression: `117/117`; +- `validateLevels`: `22` authored levels; +- `validateContentMatrix`: `4` worlds, `52` planned, `5` authored production rows, release total `69`; - `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN; - APK ZIP integrity: no errors; matrix и `level-18.json` присутствуют в package; +- cold unfiltered Android: `57/57`, `1807.264 s`; - debug APK SHA-256: - `73d9647f7d5456c79e6fcfa828e0524ccdd5cdf4777a3998c51016c38ce2652c`. + `81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef`. -Этот срез не меняет runtime, physics или geometry, поэтому device playthrough -матрица остаётся acceptance gate следующего authoring wave, а не подменяется -build-time validator тестами. +Матрица не подменяет device evidence: Levels 19–22 прошли отдельные routes, +restart DoD и полный unfiltered gate; детали — `STAGE6_FOUNDATIONS_WAVE1.md`. diff --git a/docs/STAGE6_FOUNDATIONS_WAVE1.md b/docs/STAGE6_FOUNDATIONS_WAVE1.md new file mode 100644 index 0000000..24a34ef --- /dev/null +++ b/docs/STAGE6_FOUNDATIONS_WAVE1.md @@ -0,0 +1,56 @@ +# Stage 6: Foundations wave 1 + +## Принятый результат + +Первый authoring wave расширяет каталог с 18 до 22 уровней и реализует четыре +последовательные строки executable content matrix: + +- 19 `First Return` — два раздельных jump edge, `0` смертей; +- 20 `Covered Path` — две spike-зоны, ровно `2` тела-моста; +- 21 `Held Open` — одно тело удерживает pressure Door; +- 22 `Clockwork Step` — toggle включает горизонтальную ferry platform над PIT. + +Каждый уровень имеет RU/EN copy, stable LevelID/WorldID, casualty/time targets, +collectible, solution graph и state-driven production-input route. Четыре route +methods выполняют по три последовательных запуска без teleport, прямой мутации +completion или retry masking. + +## TDD и найденные ошибки + +RED контракты сначала зафиксировали каталог `18 != 22` и отсутствующие четыре +`FoundationsScenarios`. Первый device run затем дал четыре независимых RED: + +1. Level 19 прошёл мимо слишком высокого cache (`x=1949.1761`, deaths `0`). + Cache перенесён на безопасную дальнюю прямую после двух обязательных прыжков. +2. Level 20 одним прыжком пропускал требуемое второе тело (`deaths=1`, + firstCorpseX `641.8331`). Один широкий strip разделён на две spike-зоны. +3. Level 21 повторно касался дальнего края saw (`x=734.3325`, deaths `2`). + Takeoff зафиксирован в рассчитанном окне `x≈470`; physics/saw не менялись. +4. Level 22 терял ferry до стабильной посадки (`x=1215.3331`, deaths `1`). + Toggle перенесён к launch zone, ferry получила достаточный стартовый overlap, + а bot подтверждает `playerOnGround && playerOnMovingPlatform`. + +После GREEN отдельный restart test доказал, что user restart Level 20 удаляет +тело/смерть/held input, а Level 22 сбрасывает toggle, platform motion, +collectible и input. Первый unfiltered run нашёл один acceptance drift: каталог +теперь имеет три страницы, а тест ожидал две. UI-контракт обновлён на страницы +1–3 и повторный cold run прошёл полностью. + +## Level DoD evidence + +- JSON validator: `22` levels, format/physics version `1`; +- content matrix: `4` worlds, `52` planned, `5` authored production rows, + release total `69`; +- focused routes: `4/4` methods, `12` playthroughs, `8.372 s`; +- focused restart: `1/1`; +- app JVM: `117/117`; level-format JVM: `7/7`; +- clean `lintDebug`, app APK и AndroidTest APK: GREEN; +- final cold unfiltered Android 16: `57/57`, failures/errors `0/0`, + instrumentation time `1807.264 s` (`BUILD SUCCESSFUL` in `30m21s`); +- APK ZIP: no errors; `22` level JSON packaged; +- debug APK SHA-256: + `81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef`. + +Процедурные visual/audio cues и camera bounds используют принятый общий Canvas +pipeline; wave не меняет physics/runtime systems. Следующий bounded authoring +slice — Foundations 23–26. diff --git a/level-format/src/main/resources/levels/index.json b/level-format/src/main/resources/levels/index.json index 2c704dc..113e27f 100644 --- a/level-format/src/main/resources/levels/index.json +++ b/level-format/src/main/resources/levels/index.json @@ -1 +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","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json","level-17.json","level-18.json"]} +{"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","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json","level-17.json","level-18.json","level-19.json","level-20.json","level-21.json","level-22.json"]} diff --git a/level-format/src/main/resources/levels/level-19.json b/level-format/src/main/resources/levels/level-19.json new file mode 100644 index 0000000..6d78ecf --- /dev/null +++ b/level-format/src/main/resources/levels/level-19.json @@ -0,0 +1,14 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":19, + "id":"foundations.first-return","worldId":"foundations", + "copy":{"en":{"title":"First Return","hint":"Read the two ledges, release between jumps, and keep moving toward the relic."},"ru":{"title":"Первое возвращение","hint":"Оцените два уступа, отпускайте прыжок между ними и двигайтесь к реликвии."}}, + "world":{"width":2300,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2090,"y":770,"width":86,"height":110}, + "collectible":{"x":1500,"y":790,"width":58,"height":58}, + "targets":{"deaths":0,"seconds":28}, + "platforms":[ + {"box":{"x":0,"y":880,"width":2300,"height":200}}, + {"box":{"x":650,"y":760,"width":240,"height":34}}, + {"box":{"x":1080,"y":690,"width":300,"height":34}} + ],"hazards":[],"switches":[],"doors":[] +} diff --git a/level-format/src/main/resources/levels/level-20.json b/level-format/src/main/resources/levels/level-20.json new file mode 100644 index 0000000..1026ead --- /dev/null +++ b/level-format/src/main/resources/levels/level-20.json @@ -0,0 +1,18 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":20, + "id":"foundations.covered-path","worldId":"foundations", + "copy":{"en":{"title":"Covered Path","hint":"Two volunteers can cover the spikes. Place them apart, then cross what remains."},"ru":{"title":"Накрытый путь","hint":"Два добровольца могут накрыть шипы. Разместите их раздельно и перейдите по оставшимся телам."}}, + "world":{"width":2300,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2080,"y":770,"width":86,"height":110}, + "collectible":{"x":1300,"y":665,"width":58,"height":58}, + "targets":{"deaths":2,"seconds":42}, + "platforms":[ + {"box":{"x":0,"y":880,"width":2300,"height":200}}, + {"box":{"x":1240,"y":740,"width":280,"height":34}} + ], + "hazards":[ + {"type":"SPIKES","box":{"x":700,"y":850,"width":100,"height":30}}, + {"type":"SPIKES","box":{"x":930,"y":850,"width":100,"height":30}} + ], + "switches":[],"doors":[] +} diff --git a/level-format/src/main/resources/levels/level-21.json b/level-format/src/main/resources/levels/level-21.json new file mode 100644 index 0000000..2bfdd82 --- /dev/null +++ b/level-format/src/main/resources/levels/level-21.json @@ -0,0 +1,13 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":21, + "id":"foundations.held-open","worldId":"foundations", + "copy":{"en":{"title":"Held Open","hint":"Let the saw leave one volunteer on the plate; the next can clear both body and door."},"ru":{"title":"Удержать открытым","hint":"Пусть пила оставит добровольца на плите; следующий перепрыгнет тело и открытую дверь."}}, + "world":{"width":2350,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2140,"y":770,"width":86,"height":110}, + "collectible":{"x":1160,"y":650,"width":58,"height":58}, + "targets":{"deaths":1,"seconds":45}, + "platforms":[{"box":{"x":0,"y":880,"width":2350,"height":200}}], + "hazards":[{"type":"SAW","box":{"x":650,"y":760,"width":92,"height":92},"impulseX":0,"impulseY":-150}], + "switches":[{"type":"PRESSURE","box":{"x":625,"y":860,"width":160,"height":20},"signalId":"held-gate","durationSeconds":0,"requiredMass":1}], + "doors":[{"box":{"x":1260,"y":570,"width":78,"height":310},"requiredSignals":["held-gate"]}] +} diff --git a/level-format/src/main/resources/levels/level-22.json b/level-format/src/main/resources/levels/level-22.json new file mode 100644 index 0000000..b090194 --- /dev/null +++ b/level-format/src/main/resources/levels/level-22.json @@ -0,0 +1,17 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":22, + "id":"foundations.clockwork-step","worldId":"foundations", + "copy":{"en":{"title":"Clockwork Step","hint":"Set the brass toggle, board the ferry, and wait for its far-side overlap."},"ru":{"title":"Заводная ступень","hint":"Включите латунный рычаг, встаньте на паром и дождитесь стыковки с дальним берегом."}}, + "world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2260,"y":770,"width":86,"height":110}, + "collectible":{"x":1435,"y":650,"width":58,"height":58}, + "targets":{"deaths":0,"seconds":48}, + "platforms":[ + {"box":{"x":0,"y":880,"width":1100,"height":200}}, + {"box":{"x":1600,"y":880,"width":900,"height":200}}, + {"box":{"x":940,"y":770,"width":320,"height":34},"travelX":360,"travelY":0,"periodSeconds":5.0,"controlId":"ferry"} + ], + "hazards":[{"type":"PIT","box":{"x":1100,"y":880,"width":500,"height":200}}], + "switches":[{"type":"TOGGLE","box":{"x":760,"y":820,"width":72,"height":60},"signalId":"ferry","durationSeconds":0,"requiredMass":1}], + "doors":[] +} diff --git a/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java b/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java index 7292406..870629a 100644 --- a/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java +++ b/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java @@ -85,7 +85,7 @@ public final class LevelContentToolTest { assertEquals(0, exitCode); assertEquals("Validated production content matrix: worlds=4; planned=52; " - + "authored=1; releaseTotal=69\n", + + "authored=5; releaseTotal=69\n", output.toString(StandardCharsets.UTF_8)); assertEquals("", errors.toString(StandardCharsets.UTF_8)); }