diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c5b354c..ab9690c 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: 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.\n2026-08-19: Foundations wave 2 authors Levels 23-26; matrix prefix is 9/52. Remote saw delivery, frozen-body step and conductive relay pass exact 1 death/1 corpse. Level 26 Second Hand passes original-timer and expired-timer recovery routes, both 3/3 with zero deaths; recovery explicitly observes the far Door closed before rearming the shared signal. Focused suite: 5/5 methods, 15 routes, 10.768s. Clean gate: 118/118 app JVM, 7/7 level-format, validators 26/authored9, lint/app/test APK; final cold unfiltered Android 62/62 in 1063.114s, APK SHA 67510d404cfa0704500cbf36c7845e9ba9fa05e96630079ab02bc35113e946fa. Next bounded slice is Foundations 27-30; issue remains in_progress until Level 69.\n2026-08-19: Foundations wave 3 authors Levels 27-30 and closes the world at 13/13 matrix rows (authored production prefix 13/52). Level 27 proves two retained-body AND; Level 28 proves delivered/frozen/conductive exact 3-body route plus clean restart; Level 29 CHALLENGE proves exact two bodies; Level 30 FINALE combines bridge, pressure, toggle/timed ferry, PIT and Door at exact 3 bodies. Focused suite: 4/4 methods, 12 routes, 35.188s. Late-load REDs led only to deterministic test-clock hardening and an AVD-specific performance evidence boundary; physical hardware retains full 34ms frame gate. Final clean gate: 119/119 app JVM, 7/7 level-format, validators 30/authored13, lint/app/test APK; cold unfiltered Android 66/66 in 6749.094s, APK SHA d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315. Next bounded slice is Physics 31-34; 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-19T12:25:30Z","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.\n2026-08-19: Foundations wave 2 authors Levels 23-26; matrix prefix is 9/52. Remote saw delivery, frozen-body step and conductive relay pass exact 1 death/1 corpse. Level 26 Second Hand passes original-timer and expired-timer recovery routes, both 3/3 with zero deaths; recovery explicitly observes the far Door closed before rearming the shared signal. Focused suite: 5/5 methods, 15 routes, 10.768s. Clean gate: 118/118 app JVM, 7/7 level-format, validators 26/authored9, lint/app/test APK; final cold unfiltered Android 62/62 in 1063.114s, APK SHA 67510d404cfa0704500cbf36c7845e9ba9fa05e96630079ab02bc35113e946fa. Next bounded slice is Foundations 27-30; issue remains in_progress until Level 69.\n2026-08-19: Foundations wave 3 authors Levels 27-30 and closes the world at 13/13 matrix rows (authored production prefix 13/52). Level 27 proves two retained-body AND; Level 28 proves delivered/frozen/conductive exact 3-body route plus clean restart; Level 29 CHALLENGE proves exact two bodies; Level 30 FINALE combines bridge, pressure, toggle/timed ferry, PIT and Door at exact 3 bodies. Focused suite: 4/4 methods, 12 routes, 35.188s. Late-load REDs led only to deterministic test-clock hardening and an AVD-specific performance evidence boundary; physical hardware retains full 34ms frame gate. Final clean gate: 119/119 app JVM, 7/7 level-format, validators 30/authored13, lint/app/test APK; cold unfiltered Android 66/66 in 6749.094s, APK SHA d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315. Next bounded slice is Physics 31-34; issue remains in_progress until Level 69.\n2026-08-19: Physics wave 1 authors Levels 31-34; authored production prefix is 17/52. Weight Class proves mass-3 object pressure with clean restart; Stack Order adds deterministic falling object-on-object support; Launch Window proves exact one saw body plus vertical lift; Press Cycle proves exact one crushed retained body plus active/rest crusher timing. Focused suite: 4/4 methods, 12 routes; first run 10.003s, restart-proof rerun green. TDD REDs were missing PhysicalObjectStackResolver and PhysicsWaveOneScenarios. Clean gate: 122/122 app JVM, 7/7 level-format, validators 34/authored17, lint/app/test APK; cold unfiltered Android 70/70 in 611.991s, APK SHA 1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3. Next bounded slice is Physics 35-38; 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-19T12:52:14Z","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 c668e48..2421819 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,10 @@ The final Foundations wave adds levels 27–30: a two-body AND gate, a three-sta body sequence with clean restart, an exact-two-death challenge and a finale that combines corpse bridge, pressure, toggle/timer ferry and Door. The full Foundations world now occupies its accepted 18–30 matrix range. +The first Physics wave adds levels 31–34: object-held mass, a real three-object +support stack, a saw-delivery lift route and a crusher/pressure cycle. Vertical +object-on-object support is deterministic and covered independently from player +movement; all four routes pass three consecutive production-input runs. 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 @@ -148,10 +152,11 @@ Physics, States and Mastery. Its contract and Level DoD pipeline are documented in `docs/STAGE6_CONTENT_MATRIX.md`; wave evidence is in `docs/STAGE6_FOUNDATIONS_WAVE1.md` and `docs/STAGE6_FOUNDATIONS_WAVE2.md`; the completed world evidence is in -`docs/STAGE6_FOUNDATIONS_WAVE3.md`. The latest exact gate is `119 app JVM + 7 -content-tool JVM / 66 Android` on the thirty-level candidate (`66/66`, -6749.094 seconds). The verified debug APK SHA-256 is -`d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315`. +`docs/STAGE6_FOUNDATIONS_WAVE3.md`. Physics evidence starts in +`docs/STAGE6_PHYSICS_WAVE1.md`. The latest exact gate is `122 app JVM + 7 +content-tool JVM / 70 Android` on the thirty-four-level candidate (`70/70`, +611.991 seconds). The verified debug APK SHA-256 is +`1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3`. 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/LevelPaginationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java index 5e8970f..fc84f08 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(3, view.levelPageCountForAutomation()); + assertEquals(4, view.levelPageCountForAutomation()); assertTrue(view.visibleLocalizedTextForAutomation().contains("The Volunteer")); assertFalse(view.visibleLocalizedTextForAutomation().contains("Steam Charge")); }); @@ -43,7 +43,7 @@ public final class LevelPaginationTest { assertTrue(visible.contains("Covered Path")); assertFalse(visible.contains("Held Open")); assertFalse(visible.contains("The Volunteer")); - assertTrue(visible.contains("Page 2 of 3")); + assertTrue(visible.contains("Page 2 of 4")); }); tap(activity, 1_730f, 965f); @@ -62,9 +62,25 @@ public final class LevelPaginationTest { assertTrue(visible.contains("Minimum Cost")); assertTrue(visible.contains("Foundation Relay")); assertFalse(visible.contains("Covered Path")); - assertTrue(visible.contains("Page 3 of 3")); + assertTrue(visible.contains("Page 3 of 4")); }); + tap(activity, 1_730f, 965f); + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + List visible = view.visibleLocalizedTextForAutomation(); + assertEquals(3, view.levelPageIndexForAutomation()); + assertTrue(visible.contains("Weight Class")); + assertTrue(visible.contains("Stack Order")); + assertTrue(visible.contains("Launch Window")); + assertTrue(visible.contains("Press Cycle")); + assertFalse(visible.contains("Foundation Relay")); + assertTrue(visible.contains("Page 4 of 4")); + }); + + tap(activity, 190f, 965f); + activity.onActivity(current -> + assertEquals(2, current.gameViewForAutomation().levelPageIndexForAutomation())); tap(activity, 190f, 965f); activity.onActivity(current -> assertEquals(1, current.gameViewForAutomation().levelPageIndexForAutomation())); diff --git a/app/src/androidTest/java/com/projectsacrifice/game/PhysicsWaveOnePlaythroughTest.java b/app/src/androidTest/java/com/projectsacrifice/game/PhysicsWaveOnePlaythroughTest.java new file mode 100644 index 0000000..7d24523 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/PhysicsWaveOnePlaythroughTest.java @@ -0,0 +1,111 @@ +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 PhysicsWaveOnePlaythroughTest { + @Test public void weightClassOpensFromObjectMassThreeTimes() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + Driver driver = Driver.from(activity); + for (int run = 1; run <= 3; run++) { + assertAccepted(run, new LevelPlaytestBot().run( + driver, PhysicsWaveOneScenarios.levelThirtyOne(), 55_000L), + driver, 0, 0); + } + driver.restart(); + GameView.AutomationSnapshot restarted = driver.snapshot(); + assertEquals(31, restarted.levelNumber()); + assertEquals(650f, restarted.firstPhysicalObjectX(), 0f); + assertEquals(780f, restarted.firstPhysicalObjectY(), 0f); + assertEquals(0, restarted.deaths()); + assertEquals(0, restarted.corpseCount()); + assertFalse(restarted.allDoorsOpen()); + assertFalse(restarted.collectibleFound()); + assertFalse(restarted.inputActive()); + } + } + + @Test public void stackOrderClimbsSupportedObjectsThreeTimes() { + runThree(PhysicsWaveOneScenarios.levelThirtyTwo(), 0, 0); + } + + @Test public void launchWindowUsesOneSawVolunteerAndLiftThreeTimes() { + runThree(PhysicsWaveOneScenarios.levelThirtyThree(), 1, 1); + } + + @Test public void pressCycleLeavesOneCrushedBodyThreeTimes() { + runThree(PhysicsWaveOneScenarios.levelThirtyFour(), 1, 1); + } + + 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, 55_000L); + assertAccepted(run, result, driver, expectedDeaths, expectedCorpses); + } + } + } + + private static void assertAccepted(int run, LevelPlaytestBot.Result result, Driver driver, + int expectedDeaths, int expectedCorpses) { + 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))); + } + + void restart() { + instrumentation.runOnMainSync(view::restartLevelForAutomation); + } + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index bd26a9d..d86ca28 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -906,8 +906,9 @@ public final class GameView extends View implements Runnable { object.vy += GRAVITY * dt; object.grounded = false; if (object.vx != 0f) movePhysicalObjectHorizontal(object, object.vx * dt); + float previousY = object.y; object.y += object.vy * dt; - resolvePhysicalObjectVertical(object); + resolvePhysicalObjectVertical(object, previousY); if (object.grounded) object.vx *= (float) Math.pow(0.04, dt); if (Math.abs(object.vx) < 3f) object.vx = 0f; } @@ -949,7 +950,7 @@ public final class GameView extends View implements Runnable { return Math.abs(object.x - before) + 0.01f >= Math.abs(amount); } - private void resolvePhysicalObjectVertical(PhysicalObjectState object) { + private void resolvePhysicalObjectVertical(PhysicalObjectState object, float previousY) { object.supportIndex = -1; RectF current = physicalObjectRect(object); for (int i = 0; i < platformStates.size(); i++) { @@ -987,6 +988,7 @@ public final class GameView extends View implements Runnable { object.vy = 0f; current = physicalObjectRect(object); } + PhysicalObjectStackResolver.settleFalling(object, previousY, physicalObjects); } private void updateCorpses(float dt) { diff --git a/app/src/main/java/com/projectsacrifice/game/PhysicalObjectStackResolver.java b/app/src/main/java/com/projectsacrifice/game/PhysicalObjectStackResolver.java new file mode 100644 index 0000000..e3dab25 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/PhysicalObjectStackResolver.java @@ -0,0 +1,35 @@ +package com.projectsacrifice.game; + +/** Resolves a falling authored object onto the first crossed object beneath it. */ +final class PhysicalObjectStackResolver { + private static final float CONTACT_EPSILON = 1.5f; + + private PhysicalObjectStackResolver() {} + + static boolean settleFalling(PhysicalObjectState moving, float previousY, + Iterable objects) { + if (moving == null || objects == null || moving.vy < 0f) return false; + float previousBottom = previousY + moving.height; + float currentBottom = moving.y + moving.height; + PhysicalObjectState nearest = null; + float nearestTop = Float.POSITIVE_INFINITY; + for (PhysicalObjectState support : objects) { + if (support == null || support == moving) continue; + if (moving.x >= support.x + support.width + || moving.x + moving.width <= support.x) continue; + if (previousBottom > support.y + CONTACT_EPSILON) continue; + if (currentBottom + CONTACT_EPSILON < support.y) continue; + if (moving.y >= support.y) continue; + if (support.y < nearestTop) { + nearest = support; + nearestTop = support.y; + } + } + if (nearest == null) return false; + moving.y = nearest.y - moving.height; + moving.vy = 0f; + moving.grounded = true; + moving.supportIndex = -1; + return true; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/PhysicsWaveOneScenarios.java b/app/src/main/java/com/projectsacrifice/game/PhysicsWaveOneScenarios.java new file mode 100644 index 0000000..04010d0 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/PhysicsWaveOneScenarios.java @@ -0,0 +1,144 @@ +package com.projectsacrifice.game; + +/** Production-input routes for the first Physics-world authoring wave. */ +public final class PhysicsWaveOneScenarios { + private PhysicsWaveOneScenarios() {} + + public static LevelPlaytestBot.Scenario levelThirtyOne() { + return LevelPlaytestBot.Scenario.builder(31, "Level 31 - weight class") + .step("Observe the mass-three object", 1_000L, LevelPlaytestBot.Input.none(), + snapshot -> snapshot.physicalObjectCount() == 1 + && snapshot.firstPhysicalObjectMass() == 3 + && !snapshot.allDoorsOpen()) + .step("Approach the counterweight", 2_500L, LevelPlaytestBot.Input.right(), + aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(520f))) + .step("Push mass onto the plate", 3_000L, LevelPlaytestBot.Input.right(), + aliveAtMost(0, snapshot -> snapshot.firstPhysicalObjectX() >= 920f + && snapshot.allDoorsOpen())) + .step("Release while mass holds the door", 1_000L, + LevelPlaytestBot.Input.none(), + aliveAtMost(0, GameView.AutomationSnapshot::allDoorsOpen)) + .step("Take off over the weight", 1_500L, + LevelPlaytestBot.Input.rightJump(), + aliveAtMost(0, snapshot -> !snapshot.playerOnGround() + && snapshot.playerY() <= 690f)) + .step("Land beyond the plate", 2_500L, LevelPlaytestBot.Input.right(), + aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(1_200f))) + .step("Collect the cache beyond the door", 3_000L, + LevelPlaytestBot.Input.right(), + aliveAtMost(0, snapshot -> snapshot.collectibleFound() + && snapshot.playerX() >= 1_550f)) + .step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(), + aliveAtMost(0, GameView.AutomationSnapshot::completed)) + .build(); + } + + public static LevelPlaytestBot.Scenario levelThirtyTwo() { + return LevelPlaytestBot.Scenario.builder(32, "Level 32 - stack order") + .step("Observe the supported three-object stack", 1_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.physicalObjectCount() == 3 + && snapshot.firstPhysicalObjectY() >= 799f + && snapshot.firstPhysicalObjectY() <= 801f) + .step("Stage before the stack", 2_500L, LevelPlaytestBot.Input.right(), + aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(480f))) + .step("Jump onto the lower object", 2_500L, + LevelPlaytestBot.Input.rightJump(), + aliveAtMost(0, snapshot -> snapshot.playerOnGround() + && snapshot.playerX() >= 600f && snapshot.playerY() <= 700f)) + .step("Climb to the top object", 3_000L, + LevelPlaytestBot.Input.rightJump(), + aliveAtMost(0, snapshot -> snapshot.playerOnGround() + && snapshot.playerX() >= 720f && snapshot.playerY() <= 550f)) + .step("Jump from the stack to the gallery", 3_000L, + LevelPlaytestBot.Input.rightJump(), + aliveAtMost(0, snapshot -> snapshot.playerOnGround() + && snapshot.playerX() >= 1_030f && snapshot.playerY() <= 500f + && snapshot.collectibleFound())) + .step("Cross the high gallery", 5_000L, LevelPlaytestBot.Input.right(), + aliveAtMost(0, GameView.AutomationSnapshot::completed)) + .build(); + } + + public static LevelPlaytestBot.Scenario levelThirtyThree() { + return LevelPlaytestBot.Scenario.builder(33, "Level 33 - launch window") + .step("Read one saw launch", 4_000L, LevelPlaytestBot.Input.right(), + aliveAtMost(1, snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1)) + .step("Observe the delivered body", 3_000L, LevelPlaytestBot.Input.none(), + aliveAtMost(1, snapshot -> snapshot.firstCorpseX() >= 900f)) + .step("Wait for the lift runner", 2_000L, LevelPlaytestBot.Input.none(), + aliveAtMost(1, PhysicsWaveOneScenarios::respawned)) + .step("Stage before the launch saw", 2_500L, LevelPlaytestBot.Input.right(), + aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(420f))) + .step("Take off before the launch saw", 1_500L, + LevelPlaytestBot.Input.rightJump(), + aliveAtMost(1, snapshot -> !snapshot.playerOnGround() + && snapshot.playerY() <= 620f)) + .step("Clear the launch saw", 2_000L, LevelPlaytestBot.Input.right(), + aliveAtMost(1, snapshot -> snapshot.playerX() >= 760f)) + .step("Stage before the delivered body", 1_500L, + LevelPlaytestBot.Input.right(), + aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(820f))) + .step("Clear the delivered body", 3_000L, + LevelPlaytestBot.Input.rightJump(), + aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_200f))) + .step("Approach the lift", 1_500L, LevelPlaytestBot.Input.right(), + aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_240f))) + .step("Wait for the low lift phase", 4_000L, LevelPlaytestBot.Input.none(), + aliveAtMost(1, snapshot -> snapshot.firstMovingPlatformY() >= 770f)) + .step("Board the lift", 2_500L, LevelPlaytestBot.Input.rightJump(), + aliveAtMost(1, snapshot -> snapshot.playerOnGround() + && snapshot.playerOnMovingPlatform())) + .step("Ride to the launch window", 5_000L, LevelPlaytestBot.Input.none(), + aliveAtMost(1, snapshot -> snapshot.playerOnGround() + && snapshot.playerOnMovingPlatform() + && snapshot.firstMovingPlatformY() <= 430f)) + .step("Jump onto the high gallery", 3_000L, + LevelPlaytestBot.Input.rightJump(), + aliveAtMost(1, snapshot -> snapshot.playerOnGround() + && snapshot.playerX() >= 1_750f && snapshot.playerY() <= 400f)) + .step("Collect the upper cache", 2_500L, LevelPlaytestBot.Input.right(), + aliveAtMost(1, snapshot -> snapshot.collectibleFound())) + .step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(), + aliveAtMost(1, GameView.AutomationSnapshot::completed)) + .build(); + } + + public static LevelPlaytestBot.Scenario levelThirtyFour() { + return LevelPlaytestBot.Scenario.builder(34, "Level 34 - press cycle") + .step("Leave one volunteer in the press", 4_000L, + LevelPlaytestBot.Input.right(), + aliveAtMost(1, snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1 + && snapshot.firstCorpseCrushed() + && snapshot.firstCorpseActivatesPressure())) + .step("Wait for the cycle runner", 2_000L, LevelPlaytestBot.Input.none(), + aliveAtMost(1, PhysicsWaveOneScenarios::respawned)) + .step("Approach the occupied press", 2_500L, + LevelPlaytestBot.Input.right(), + aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(500f))) + .step("Observe the press close", 5_000L, LevelPlaytestBot.Input.none(), + aliveAtMost(1, GameView.AutomationSnapshot::firstHazardActive)) + .step("Wait for the full rest", 5_000L, LevelPlaytestBot.Input.none(), + aliveAtMost(1, snapshot -> !snapshot.firstHazardActive())) + .step("Jump through the resting press", 2_500L, + LevelPlaytestBot.Input.rightJump(), + aliveAtMost(1, snapshot -> snapshot.playerX() >= 930f)) + .step("Collect the press cache", 3_000L, LevelPlaytestBot.Input.right(), + aliveAtMost(1, snapshot -> snapshot.collectibleFound())) + .step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(), + aliveAtMost(1, GameView.AutomationSnapshot::completed)) + .build(); + } + + private static LevelPlaytestBot.SnapshotCondition aliveAtMost( + int deaths, LevelPlaytestBot.SnapshotCondition condition) { + return LevelPlaytestBot.whileDeathsAtMost(deaths, + snapshot -> snapshot.playerAlive() && condition.reached(snapshot)); + } + + private static boolean respawned(GameView.AutomationSnapshot snapshot) { + return snapshot.playerAlive() && snapshot.playerX() < 250f; + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/FoundationsFinaleContentTest.java b/app/src/test/java/com/projectsacrifice/game/FoundationsFinaleContentTest.java index f744d1d..56af270 100644 --- a/app/src/test/java/com/projectsacrifice/game/FoundationsFinaleContentTest.java +++ b/app/src/test/java/com/projectsacrifice/game/FoundationsFinaleContentTest.java @@ -13,7 +13,7 @@ public final class FoundationsFinaleContentTest { @Test public void finalFoundationsWaveMatchesTheApprovedMatrixAndClosesTheWorld() { List levels = LevelCatalog.all(); - assertEquals(30, levels.size()); + assertTrue(levels.size() >= 30); LevelDefinition keys = levels.get(26); assertIdentity(keys, 27, "foundations.two-keys", "Two Keys", 2, 65); diff --git a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java index 3d1aa3b..5d77c70 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(30, levels.size()); + assertEquals(34, levels.size()); Set titles = new HashSet<>(); for (int i = 0; i < levels.size(); i++) { LevelDefinition level = levels.get(i); diff --git a/app/src/test/java/com/projectsacrifice/game/PhysicalObjectStackResolverTest.java b/app/src/test/java/com/projectsacrifice/game/PhysicalObjectStackResolverTest.java new file mode 100644 index 0000000..5696638 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/PhysicalObjectStackResolverTest.java @@ -0,0 +1,47 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; + +public final class PhysicalObjectStackResolverTest { + @Test + public void fallingObjectSettlesOnTheHighestCrossedObject() { + PhysicalObjectState lower = object(650f, 800f, 160f, 80f); + PhysicalObjectState middle = object(690f, 721f, 140f, 80f) + .velocity(0f, 60f); + PhysicalObjectState distant = object(1_200f, 700f, 100f, 100f); + + boolean settled = PhysicalObjectStackResolver.settleFalling( + middle, 720f, Arrays.asList(lower, middle, distant)); + + assertTrue(settled); + assertEquals(720f, middle.weightedY(), 0f); + assertEquals(0f, middle.velocityY(), 0f); + assertTrue(middle.grounded()); + } + + @Test + public void risingOrNonCrossingObjectIsNotTeleportedOntoAStack() { + PhysicalObjectState lower = object(650f, 800f, 160f, 80f); + PhysicalObjectState rising = object(690f, 700f, 120f, 80f) + .velocity(0f, -40f); + PhysicalObjectState beside = object(300f, 721f, 100f, 80f) + .velocity(0f, 60f); + + assertFalse(PhysicalObjectStackResolver.settleFalling( + rising, 704f, Arrays.asList(lower, rising))); + assertFalse(PhysicalObjectStackResolver.settleFalling( + beside, 720f, Arrays.asList(lower, beside))); + } + + private static PhysicalObjectState object(float x, float y, float width, float height) { + return new PhysicalObjectState(new LevelDefinition.PhysicalObject( + LevelDefinition.PhysicalObjectType.BOX, + new LevelDefinition.Box(x, y, width, height), 1)); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/PhysicsWaveOneContentTest.java b/app/src/test/java/com/projectsacrifice/game/PhysicsWaveOneContentTest.java new file mode 100644 index 0000000..cabc295 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/PhysicsWaveOneContentTest.java @@ -0,0 +1,64 @@ +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 PhysicsWaveOneContentTest { + @Test + public void firstPhysicsWaveMatchesTheExecutableMatrix() { + List levels = LevelCatalog.all(); + assertEquals(34, levels.size()); + + LevelDefinition mass = levels.get(30); + assertIdentity(mass, 31, "physics.mass-basics", "Weight Class", 0, 45); + assertFalse(mass.objects.isEmpty()); + assertTrue(mass.containsMechanic(LevelDefinition.SwitchType.PRESSURE)); + assertFalse(mass.doors.isEmpty()); + + LevelDefinition stack = levels.get(31); + assertIdentity(stack, 32, "physics.stack-order", "Stack Order", 0, 50); + assertTrue(stack.objects.size() >= 3); + assertTrue("The authored staircase must contain an object supported by another", + hasAuthoredObjectStack(stack)); + + LevelDefinition launch = levels.get(32); + assertIdentity(launch, 33, "physics.launch-window", "Launch Window", 1, 55); + assertTrue(launch.containsMechanic(LevelDefinition.HazardType.SAW)); + assertTrue(launch.hasMovingPlatform()); + + LevelDefinition crusher = levels.get(33); + assertIdentity(crusher, 34, "physics.crusher-rhythm", "Press Cycle", 1, 55); + assertTrue(crusher.containsMechanic(LevelDefinition.HazardType.CRUSHER)); + assertTrue(crusher.containsMechanic(LevelDefinition.SwitchType.PRESSURE)); + } + + private static boolean hasAuthoredObjectStack(LevelDefinition level) { + for (LevelDefinition.PhysicalObject upper : level.objects) { + for (LevelDefinition.PhysicalObject lower : level.objects) { + if (upper == lower) continue; + boolean horizontalOverlap = upper.box.x < lower.box.right() + && upper.box.right() > lower.box.x; + if (horizontalOverlap + && Math.abs(upper.box.bottom() - lower.box.y) <= 0.01f) return true; + } + } + return false; + } + + 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("physics", level.worldId); + assertEquals(title, level.title); + assertEquals(targetDeaths, level.targetDeaths); + assertEquals(targetSeconds, level.targetSeconds); + assertNotNull(level.collectible); + } +} diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index eacb383..b0c7ab7 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -231,6 +231,14 @@ bridge → pressure → toggle/timed ferry → Door имеют solution graphs, `66/66` за `6749.094 s`, app JVM `119/119`, content-tool JVM `7/7`; APK содержит 30 level JSON. Evidence — `STAGE6_FOUNDATIONS_WAVE3.md`. Мир Foundations 18–30 закрыт; следующий bounded slice — Physics 31–34. +Первый Physics wave Levels 31–34 принят: mass-three object удерживает Door, +трёхобъектная лестница использует новый детерминированный object/object support, +saw-delivery соединён с вертикальным lift, crushed body сохраняется на pressure +во время полного crusher cycle. Четыре метода доказали 12/12 production-input +routes и restart сдвинутого противовеса. Cold unfiltered candidate прошёл Android +`70/70` за `611.991 s`, app JVM `122/122`, content-tool JVM `7/7`; APK содержит +34 level JSON. Evidence — `STAGE6_PHYSICS_WAVE1.md`. Следующий bounded slice — +Physics 35–38. ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней diff --git a/docs/LEVEL_SOLUTION_GRAPHS.md b/docs/LEVEL_SOLUTION_GRAPHS.md index 5b22bc4..72c0974 100644 --- a/docs/LEVEL_SOLUTION_GRAPHS.md +++ b/docs/LEVEL_SOLUTION_GRAPHS.md @@ -683,6 +683,78 @@ LevelID="foundations.foundation-final", WorldID="foundations" corpse bridge, pressure, toggle-controlled ferry, timed AND-door и collectible; каждая смерть и реальная опора на moving platform наблюдаются ботом. +## Уровень 31 — `Weight Class` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения; +после них restart возвращает сдвинутый объект и Door в исходное состояние. +**Ожидаемые и доказанные смерти:** `0`. + +```text +LevelID="physics.mass-basics", WorldID="physics" +→ игрок толкает COUNTERWEIGHT mass=3 с x=650 на pressure x=940..1190 +→ requiredMass=3 открывает mass-door без присутствия игрока на плите +→ input отпускается, Door остаётся открытой только массой объекта +→ игрок перепрыгивает противовес, забирает cache x=1580 +→ проходит Door x=1370 и достигает goal без смертей +→ restart возвращает object x=650,y=780, закрытую Door и чистый input/cache +``` + +**Контракт:** это первый production-уровень Physics и отдельное обучение +`weighted-object`; pressure resolver не различает player/corpse/object по типу. + +## Уровень 32 — `Stack Order` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `0`. + +```text +LevelID="physics.stack-order", WorldID="physics" +→ BOX x=650,y=800 поддерживает ROCK x=700,y=720 +→ ROCK поддерживает ICE_BLOCK x=760,y=640; solver сохраняет обе опоры +→ игрок отдельными jump edge поднимается на нижний и верхний объекты +→ со стопки прыгает на gallery x=1030,y=600 и забирает cache x=900,y=555 +→ по верхней галерее достигает goal x=2260,y=490 без смертей +``` + +**Контракт:** vertical object/object contact выбирает первую пересечённую опору +снизу, не телепортирует восходящие или не пересекающиеся объекты и не меняет +существующую player physics. + +## Уровень 33 — `Launch Window` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `1`; saw launch сохраняет одно тело. + +```text +LevelID="physics.launch-window", WorldID="physics" +→ первый доброволец входит в saw x=600 и получает impulse (700,-420) +→ следующий начинает прыжок до saw и отдельным edge очищает доставленное тело +→ у нижней фазы садится на lift x=1350,y≈790 +→ snapshot подтверждает реальную moving-platform support +→ едет до y≤430 и прыгает на gallery x=1750,y=500 +→ забирает cache x=2050 и достигает верхнего goal с deaths=1, corpses=1 +``` + +**Контракт:** уровень сочетает уже обученный saw-launch с вертикальной движущейся +опорой; бот наблюдает смерть, доставленное тело, посадку и верхнюю фазу отдельно. + +## Уровень 34 — `Press Cycle` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `1`; crushed body сохраняется на pressure. + +```text +LevelID="physics.crusher-rhythm", WorldID="physics" +→ первый доброволец погибает в CRUSHER x=680..850 +→ crushed body становится низкой retained mass на pressure x=650..880 +→ следующий наблюдает active и inactive фазы полного 4.5 s цикла +→ одним новым jump edge пересекает resting press без второй смерти +→ забирает cache x=1320 и достигает goal с deaths=1, corpses=1 +``` + +**Контракт:** pressure фиксирует результат жертвы, а timing остаётся обязательным: +каждый шаг fail-fast отклоняет вторую смерть и не перезапускает цикл. + ## Проверки, следующие из графов 1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8. diff --git a/docs/STAGE6_CONTENT_MATRIX.md b/docs/STAGE6_CONTENT_MATRIX.md index a833ffb..48d4334 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=13; releaseTotal=69 +Validated production content matrix: worlds=4; planned=52; authored=17; releaseTotal=69 ``` ## Content pipeline и Level DoD @@ -64,9 +64,9 @@ JSON-уровень добавляется только ведущим преф 5. restart, blocker-softlock и collectible/challenge проверки; 6. camera, sound, art, optimization и QA regression gates из Level DoD. -Foundations 18–30 полностью авторизованы через этот pipeline. Следующий bounded -slice — Physics 31–34; незавершённый уровень не скрывается в общем счётчике -production-контента. +Foundations 18–30 и Physics 31–34 полностью авторизованы через этот pipeline. +Следующий bounded slice — Physics 35–38; незавершённый уровень не скрывается в +общем счётчике production-контента. ## TDD и текущий gate @@ -75,19 +75,20 @@ errors), затем отсутствующую CLI-команду `validate-matr добавлен отрицательный regression: изменение planned title Level 18 отклоняется как drift реального JSON-префикса. -Текущий gate после завершения мира Foundations на 19 августа 2026 года: +Текущий gate после первого Physics wave на 19 августа 2026 года: - `ProductionContentMatrixTest` + `LevelContentToolTest`: `7/7`; -- app JVM regression: `119/119`; -- `validateLevels`: `30` authored levels; -- `validateContentMatrix`: `4` worlds, `52` planned, `13` authored production rows, +- app JVM regression: `122/122`; +- `validateLevels`: `34` authored levels; +- `validateContentMatrix`: `4` worlds, `52` planned, `17` authored production rows, release total `69`; - `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN; -- APK ZIP integrity: no errors; `30` level JSON присутствуют в package; -- cold unfiltered Android: `66/66`, failures/errors/skipped `0/0/0`, `6749.094 s`; +- APK ZIP integrity: no errors; `34` level JSON присутствуют в package; +- cold unfiltered Android: `70/70`, failures/errors/skipped `0/0/0`, `611.991 s`; - debug APK SHA-256: - `d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315`. + `1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3`. -Матрица не подменяет device evidence: Foundations 18–30 прошёл отдельные routes -и полный unfiltered gate; детали — `STAGE6_FOUNDATIONS_WAVE1.md`, -`STAGE6_FOUNDATIONS_WAVE2.md` и `STAGE6_FOUNDATIONS_WAVE3.md`. +Матрица не подменяет device evidence: Foundations 18–30 и Physics 31–34 прошли +отдельные routes и полный unfiltered gate; детали — `STAGE6_FOUNDATIONS_WAVE1.md`, +`STAGE6_FOUNDATIONS_WAVE2.md`, `STAGE6_FOUNDATIONS_WAVE3.md` и +`STAGE6_PHYSICS_WAVE1.md`. diff --git a/docs/STAGE6_PHYSICS_WAVE1.md b/docs/STAGE6_PHYSICS_WAVE1.md new file mode 100644 index 0000000..a04e13c --- /dev/null +++ b/docs/STAGE6_PHYSICS_WAVE1.md @@ -0,0 +1,48 @@ +# Stage 6: Physics wave 1 + +## Принятый результат + +Первый bounded authoring wave мира Physics добавляет уровни 31–34: + +- 31 `Weight Class` — COUNTERWEIGHT mass 3 самостоятельно удерживает pressure Door; +- 32 `Stack Order` — три authored objects образуют реальную вертикальную опору; +- 33 `Launch Window` — одна saw-жертва, доставленное тело и вертикальный lift; +- 34 `Press Cycle` — crushed body остаётся на pressure, следующий игрок читает + active/rest фазы crusher. + +Все четыре уровня совпадают с executable matrix по stable LevelID/WorldID, +RU/EN copy, targets и collectible. Четыре Android methods выполняют по три +последовательных production-input route: `12/12`, без retry или прямой мутации. +Level 31 после трёх маршрутов дополнительно доказывает restart: объект возвращён +в `(650,780)`, Door закрыта, deaths/corpses/cache/input очищены. + +## Object-stacking TDD + +Первый RED был точным compile failure: `PhysicalObjectStackResolver` отсутствовал. +Второй RED зафиксировал отсутствующий `PhysicsWaveOneScenarios`. Минимальная GREEN +реализация добавляет только vertical object/object contact: + +- falling object выбирает верхнюю первую пересечённую опору; +- rising object, объект сбоку и не пересёкший верх опоры не телепортируются; +- support обнуляет vertical velocity и публикует grounded state; +- player movement, run/jump constants и прежняя geometry не менялись. + +`physicsVersion` остаётся `1`: существующие уровни не авторизуют вертикально +пересекающиеся objects, поэтому принятые trace/geometry не меняются; новый контакт +активируется только новой stacked geometry Level 32. + +## Device evidence + +- targeted first run: `4/4` methods, `12` routes, `10.003 s`; +- targeted rerun после restart-proof: `4/4`, GREEN; +- cold unfiltered Android 16: `70/70`, failures/errors/skipped `0/0/0`, + instrumentation `611.991 s`, `BUILD SUCCESSFUL in 10m23s`; +- app JVM: `122/122`; level-format JVM: `7/7`; +- clean `lintDebug`, app APK и AndroidTest APK: GREEN; +- validator: `34` levels; matrix `17/52` authored production rows; +- APK ZIP: no errors; `34` level JSON packaged; +- debug APK SHA-256: + `1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3`. + +Следующий bounded slice — Physics 35–38: moving hazard, returning projectile, +blast delivery и moving load. diff --git a/level-format/src/main/resources/levels/index.json b/level-format/src/main/resources/levels/index.json index 1598f19..e34b21d 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","level-19.json","level-20.json","level-21.json","level-22.json","level-23.json","level-24.json","level-25.json","level-26.json","level-27.json","level-28.json","level-29.json","level-30.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","level-23.json","level-24.json","level-25.json","level-26.json","level-27.json","level-28.json","level-29.json","level-30.json","level-31.json","level-32.json","level-33.json","level-34.json"]} diff --git a/level-format/src/main/resources/levels/level-31.json b/level-format/src/main/resources/levels/level-31.json new file mode 100644 index 0000000..b6a484f --- /dev/null +++ b/level-format/src/main/resources/levels/level-31.json @@ -0,0 +1,14 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":31, + "id":"physics.mass-basics","worldId":"physics", + "copy":{"en":{"title":"Weight Class","hint":"Push the marked counterweight onto the plate; mass, not the player, keeps the door open."},"ru":{"title":"Весовая категория","hint":"Затолкните отмеченный противовес на плиту: дверь удерживает масса, а не игрок."}}, + "world":{"width":2300,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2070,"y":770,"width":86,"height":110}, + "collectible":{"x":1580,"y":790,"width":58,"height":58}, + "targets":{"deaths":0,"seconds":45}, + "objects":[{"type":"COUNTERWEIGHT","box":{"x":650,"y":780,"width":90,"height":100},"mass":3}], + "platforms":[{"box":{"x":0,"y":880,"width":2300,"height":200}}], + "hazards":[], + "switches":[{"type":"PRESSURE","box":{"x":940,"y":860,"width":250,"height":20},"signalId":"mass-door","durationSeconds":0,"requiredMass":3}], + "doors":[{"box":{"x":1370,"y":560,"width":84,"height":320},"requiredSignals":["mass-door"]}] +} diff --git a/level-format/src/main/resources/levels/level-32.json b/level-format/src/main/resources/levels/level-32.json new file mode 100644 index 0000000..91a2709 --- /dev/null +++ b/level-format/src/main/resources/levels/level-32.json @@ -0,0 +1,19 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":32, + "id":"physics.stack-order","worldId":"physics", + "copy":{"en":{"title":"Stack Order","hint":"Climb the supported object staircase in order, then jump onto the high gallery."},"ru":{"title":"Порядок стопки","hint":"Поднимитесь по опирающимся друг на друга объектам и прыгните на высокую галерею."}}, + "world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2260,"y":490,"width":86,"height":110}, + "collectible":{"x":900,"y":555,"width":58,"height":58}, + "targets":{"deaths":0,"seconds":50}, + "objects":[ + {"type":"BOX","box":{"x":650,"y":800,"width":160,"height":80},"mass":1}, + {"type":"ROCK","box":{"x":700,"y":720,"width":140,"height":80},"mass":2}, + {"type":"ICE_BLOCK","box":{"x":760,"y":640,"width":120,"height":80},"mass":2} + ], + "platforms":[ + {"box":{"x":0,"y":880,"width":1080,"height":200}}, + {"box":{"x":1030,"y":600,"width":1470,"height":480}} + ], + "hazards":[],"switches":[],"doors":[] +} diff --git a/level-format/src/main/resources/levels/level-33.json b/level-format/src/main/resources/levels/level-33.json new file mode 100644 index 0000000..29f08a6 --- /dev/null +++ b/level-format/src/main/resources/levels/level-33.json @@ -0,0 +1,18 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":33, + "id":"physics.launch-window","worldId":"physics", + "copy":{"en":{"title":"Launch Window","hint":"Read the saw launch once, clear the delivered body, then board the moving bridge at its near edge."},"ru":{"title":"Окно запуска","hint":"Один раз изучите бросок пилы, перепрыгните доставленное тело и садитесь на мост у ближнего края."}}, + "world":{"width":2900,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2660,"y":390,"width":86,"height":110}, + "collectible":{"x":2050,"y":410,"width":58,"height":58}, + "targets":{"deaths":1,"seconds":55}, + "platforms":[ + {"box":{"x":0,"y":880,"width":2900,"height":200}}, + {"box":{"x":1750,"y":500,"width":1150,"height":34}}, + {"box":{"x":1350,"y":790,"width":300,"height":34},"travelX":0,"travelY":-390,"periodSeconds":5.0} + ], + "hazards":[ + {"type":"SAW","box":{"x":600,"y":760,"width":96,"height":96},"impulseX":700,"impulseY":-420} + ], + "switches":[],"doors":[] +} diff --git a/level-format/src/main/resources/levels/level-34.json b/level-format/src/main/resources/levels/level-34.json new file mode 100644 index 0000000..6267915 --- /dev/null +++ b/level-format/src/main/resources/levels/level-34.json @@ -0,0 +1,15 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":34, + "id":"physics.crusher-rhythm","worldId":"physics", + "copy":{"en":{"title":"Press Cycle","hint":"Leave one low body on the plate, read the press cycle, and cross during its full rest."},"ru":{"title":"Цикл пресса","hint":"Оставьте одно низкое тело на плите, прочитайте цикл пресса и пройдите в полный покой."}}, + "world":{"width":2450,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2210,"y":770,"width":86,"height":110}, + "collectible":{"x":1320,"y":790,"width":58,"height":58}, + "targets":{"deaths":1,"seconds":55}, + "platforms":[{"box":{"x":0,"y":880,"width":2450,"height":200}}], + "hazards":[{"type":"CRUSHER","box":{"x":680,"y":690,"width":170,"height":190}, + "impulseX":40,"impulseY":100,"cycleSeconds":4.5,"activeSeconds":2.4, + "speedX":0,"speedY":0,"visualStyle":"steel-press","audioCue":"crusher-impact"}], + "switches":[{"type":"PRESSURE","box":{"x":650,"y":860,"width":230,"height":20},"signalId":"pressed-mass","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 9b896dd..56e27af 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=13; releaseTotal=69\n", + + "authored=17; releaseTotal=69\n", output.toString(StandardCharsets.UTF_8)); assertEquals("", errors.toString(StandardCharsets.UTF_8)); }