diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d66f13b..91362f0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -23,7 +23,7 @@ {"_type":"issue","id":"goon-game-e12","title":"EPIC-12 Android","description":"Lifecycle, устройства, aspect ratios, graphics profiles и сборки. Разделы 41, 44–46, 49.","acceptance_criteria":"Release build стабильно работает на целевой Android-матрице.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:08Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:47Z","metadata":{"stages":"0,3,5,7","primary_stage":7},"labels":["android","epic","primary-stage-7","stage-0","stage-3","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e12","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:55Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e07","title":"EPIC-07 Levels","description":"Solution graphs, greybox, прогрессия механик и производство 55–70 уровней. Разделы 19–25, 52–53, 66.","acceptance_criteria":"Каждый уровень имеет доказанное решение, DoD и место в content matrix.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:57Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:25Z","metadata":{"stages":"0,2,4,6","primary_stage":6},"labels":["content","epic","levels","primary-stage-6","stage-0","stage-2","stage-4","stage-6"],"dependencies":[{"issue_id":"goon-game-e07","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:49Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e03","title":"EPIC-03 Physics","description":"Предсказуемая гибридная физика и бюджеты активных объектов. Разделы 15, 41–43, 65.","acceptance_criteria":"Одинаковая последовательность ввода воспроизводимо приводит к одинаковому состоянию головоломки.","notes":"Stage 5 closed the deterministic physics contract: PhysicsConfig v1 and 10 exact command traces replayed 3x with identical signatures; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:55Z","closed_at":"2026-08-18T23:02:55Z","close_reason":"Physics foundation and deterministic replay acceptance are complete.","metadata":{"stages":"0,5,7","primary_stage":5},"labels":["epic","physics","primary-stage-5","stage-0","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e03","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"goon-game-e02","title":"EPIC-02 Death \u0026 Corpse","description":"Смерть как игровая механика, состояния тела, CorpseManager и respawn. Разделы 4–8.","acceptance_criteria":"Все типы смерти создают предсказуемое и пригодное для головоломки состояние тела.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:03Z","metadata":{"stages":"0,6,7","primary_stage":6},"labels":["corpse","death","epic","primary-stage-6","stage-0","stage-6","stage-7"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"goon-game-e02","title":"EPIC-02 Death \u0026 Corpse","description":"Смерть как игровая механика, состояния тела, CorpseManager и respawn. Разделы 4–8.","acceptance_criteria":"Все типы смерти создают предсказуемое и пригодное для головоломки состояние тела.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:50Z","closed_at":"2026-08-19T03:32:50Z","close_reason":"All EPIC-02 children complete: death/respawn core, CorpseManager ownership, and every approved corpse state has deterministic runtime and puzzle evidence.","metadata":{"stages":"0,6,7","primary_stage":6},"labels":["corpse","death","epic","primary-stage-6","stage-0","stage-6","stage-7"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-plan","title":"Project Sacrifice: выполнить утверждённый production plan","description":"Главный bead для плана из docs/DEVELOPMENT_PLAN.md: vertical slice, production, alpha, beta и release candidate.","acceptance_criteria":"Release Candidate удовлетворяет критериям раздела 59; все дочерние эпики завершены либо явно исключены решением владельца продукта.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:23Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:51Z","started_at":"2026-08-17T21:41:28Z","metadata":{"stages":"0,1,2,3,4,5,6,7,8,9,10","primary_stage":10},"labels":["gdd","master","primary-stage-10","production","stage-0","stage-1","stage-10","stage-2","stage-3","stage-4","stage-5","stage-6","stage-7","stage-8","stage-9","tdd"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-stage4-bot-traces","title":"Сохранять воспроизводимый журнал команд бота","description":"Добавить versioned trace contract для production-input сценариев с level/format/physics versions, simulation timestamps, inputs и ключевыми checkpoints.","design":"Trace recorder наблюдает публичный Driver/Input контракт и не управляет GameView напрямую; replay engine вне Stage 4 scope, журнал должен быть пригоден для последующего replay.","acceptance_criteria":"Каждый bot runner может выдать deterministic JSON trace; повторная сериализация идентична; timeout diagnostics указывает trace; sensitive/device-local data не записываются.","notes":"GREEN: trace v1 records simulation inputs/checkpoints/outcome plus format/physics versions; SHA-256 trace ID is included in diagnostics; generic and level-8 runners covered.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:04Z","started_at":"2026-08-18T19:01:26Z","closed_at":"2026-08-18T19:01:34Z","close_reason":"Deterministic versioned traces and diagnostic IDs implemented for all bot runner families.","metadata":{"stages":4,"primary_stage":4},"labels":["bot","diagnostics","primary-stage-4","replay","stage-4","stage4"],"dependencies":[{"issue_id":"goon-game-stage4-bot-traces","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:32Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-stage4-validator-tools","title":"Добавить build validator и desktop preview/export","description":"Создать запускаемый на desktop инструмент, который валидирует каталог до Android-сборки и экспортирует координаты в читаемый SVG/текстовый отчёт.","design":"Инструмент использует ту же pure-Java загрузку и validator, что runtime, без дублирования правил в Gradle или Python.","acceptance_criteria":"Gradle build отклоняет broken fixture до упаковки APK; CLI валидирует все levels и создаёт deterministic preview/export; команда и формат задокументированы.","notes":"GREEN: preBuild validateLevels, path-specific broken-signal rejection, deterministic 10 SVG + catalog.tsv preview.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:28Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:04Z","started_at":"2026-08-18T19:01:11Z","closed_at":"2026-08-18T19:01:21Z","close_reason":"Shared build validator and deterministic desktop preview/export implemented, documented, and verified.","metadata":{"stages":4,"primary_stage":4},"labels":["primary-stage-4","stage-4","stage4","tooling","validator"],"dependencies":[{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:31Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-level-json","type":"blocks","created_at":"2026-08-18T20:54:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} @@ -70,7 +70,7 @@ {"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","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 одинаково работает с персонажем, телом и объектами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","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 механики.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","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} +{"_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} {"_type":"issue","id":"goon-game-e14","title":"EPIC-14 Analytics","description":"События прохождения, позиции смертей и crash reporting без лишних персональных данных. Раздел 47.","acceptance_criteria":"События и ошибки проверены в тестовом канале, схема версионирована.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:12Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:57Z","metadata":{"stages":"7,8,9,10","primary_stage":7},"labels":["analytics","epic","primary-stage-7","stage-10","stage-7","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e14","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:57Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e11","title":"EPIC-11 Art","description":"Самостоятельное художественное направление, модульные персонажи и анимации. Разделы 26–28.","acceptance_criteria":"Визуальный язык утверждён, оригинален и покрывает все игровые состояния.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:44Z","metadata":{"stages":"5,6","primary_stage":6},"labels":["art","epic","primary-stage-6","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e11","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e10","title":"EPIC-10 Audio","description":"Sound design, вариативность реакций и музыкальные loops. Разделы 29–30.","acceptance_criteria":"Vertical slice имеет полный audio pass без раздражающих повторов.","notes":"Stage 5 audio acceptance complete: procedural music/effects/voice variation, lifecycle safety and independent persisted controls; the EPIC child is closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:03Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:09Z","closed_at":"2026-08-18T23:03:09Z","close_reason":"Vertical Slice has a complete non-repeating audio pass.","metadata":{"stages":5,"primary_stage":5},"labels":["audio","epic","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-e10","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:52Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index ce60870..ac2ae81 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,14 @@ Project documents: - [`docs/STAGE5_ART_BIBLE.md`](docs/STAGE5_ART_BIBLE.md) — original visual language and articulated volunteer contract; - [`docs/STAGE5_PERFORMANCE_BUDGETS.md`](docs/STAGE5_PERFORMANCE_BUDGETS.md) — physics, replay, frame, memory, and complexity ceilings; - [`docs/STAGE5_QA_REPORT.md`](docs/STAGE5_QA_REPORT.md) — exact Vertical Slice candidate evidence; +- [`docs/STAGE6_CORPSE_STATES.md`](docs/STAGE6_CORPSE_STATES.md) — burned/crushed body contracts and acceptance evidence; - [`docs/STAGE5_HUMAN_PLAYTEST.md`](docs/STAGE5_HUMAN_PLAYTEST.md) — required external acceptance protocol; - [`docs/STAGE_REGISTRY.md`](docs/STAGE_REGISTRY.md) — mandatory Stage 0–10 ownership for every Bead; - [`docs/BEADS_WORKFLOW.md`](docs/BEADS_WORKFLOW.md) — local backlog structure and daily Beads commands. ## Playable content -The APK contains ten sequential levels: +The APK contains sixteen sequential levels: 1. movement and jumping; 2. bodies covering a spike lane; @@ -28,7 +29,13 @@ The APK contains ten sequential levels: 7. an electrified corpse completing a relay; 8. a timed switch and moving platform; 9. a two-signal door requiring weight and electricity; -10. a mastery level combining saw, freeze, electricity, pressure plate, pit, moving lift, and final gate. +10. a mastery level combining saw, freeze, electricity, pressure plate, pit, moving lift, and final gate; +11. a rhythmic crusher and its safe rest phase; +12. a fire lane with a staged jump; +13. two state-driven projectile crossings; +14. explosion delivery to a remote pressure door; +15. a crushed body whose low collider becomes a required step; +16. a burned body that stays solid but no longer powers a pressure bridge. Death does not reset the level. A new volunteer respawns after a short delay while retained bodies stay in the simulation. The restart button deliberately clears the puzzle state. Progress, best time, and lowest volunteer count are stored locally. @@ -72,7 +79,7 @@ Previews are written to `level-format/build/level-previews`. Invalid geometry, versions, moving-platform destinations, switch parameters, or signal references fail the build with a JSON path diagnostic. -All ten levels have device-level acceptance bots. They drive the production +All sixteen levels have device-level acceptance bots. They drive the production `GameView` through the same movement and jump inputs as the touch controls and run each intended solution three times. Level 8 also has a dedicated recovery scenario for an expired timer on the far side of the pit. Stage 2 additionally @@ -112,8 +119,9 @@ mechanics slice adds data-driven crusher, fire, projectile and explosion hazards teaching levels 11–14, dynamic level pagination and production-input routes. 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`. The latest unfiltered -gate is `99 JVM / 43 Android`. +hazard evidence is in `docs/STAGE6_PRODUCTION_HAZARDS.md`, and the burned/crushed +contract is in `docs/STAGE6_CORPSE_STATES.md`. The latest exact gate is +`102 JVM / 46 Android` on the sixteen-level candidate. 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/CorpseStateIntegrationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/CorpseStateIntegrationTest.java new file mode 100644 index 0000000..1b29d98 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/CorpseStateIntegrationTest.java @@ -0,0 +1,40 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class CorpseStateIntegrationTest { + @Test + public void crusherAndFireDeathsPublishDistinctPuzzleBodyStates() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.enableDeterministicAutomationClock(); + + view.startLevelForAutomation(11); + view.setAutomationInput(false, true, false); + view.advanceDeterministicAutomation(2_000L); + GameView.AutomationSnapshot crushed = view.automationSnapshot(); + assertTrue(crushed.firstCorpseCrushed()); + assertFalse(crushed.firstCorpseBurned()); + assertTrue(crushed.firstCorpseActivatesPressure()); + assertTrue(crushed.firstCorpseWidth() > crushed.firstCorpseHeight()); + + view.startLevelForAutomation(12); + view.setAutomationInput(false, true, false); + view.advanceDeterministicAutomation(2_000L); + GameView.AutomationSnapshot burned = view.automationSnapshot(); + assertTrue(burned.firstCorpseBurned()); + assertFalse(burned.firstCorpseCrushed()); + assertFalse(burned.firstCorpseActivatesPressure()); + }); + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/CorpseStatePlaythroughTest.java b/app/src/androidTest/java/com/projectsacrifice/game/CorpseStatePlaythroughTest.java new file mode 100644 index 0000000..4874830 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/CorpseStatePlaythroughTest.java @@ -0,0 +1,81 @@ +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 CorpseStatePlaythroughTest { + private static final int CONSECUTIVE_RUNS = 3; + + @Test public void crushedBodyIsARequiredLowStepThreeTimes() { + prove(CorpseStateScenarios.levelFifteen(), 45_000L); + } + + @Test public void burnedBodyLeavesThePressureBridgeDormantThreeTimes() { + prove(CorpseStateScenarios.levelSixteen(), 45_000L); + } + + private static void prove(LevelPlaytestBot.Scenario scenario, long budget) { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + AndroidDriver driver = AndroidDriver.from(activity); + for (int run = 1; run <= CONSECUTIVE_RUNS; run++) { + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, budget); + assertTrue("run=" + run + "; " + result.diagnostic(), result.completed()); + assertEquals("run=" + run + "; route uses exactly one transformed body", + 1, result.deaths()); + assertEquals("run=" + run + "; transformed body must remain", + 1, result.corpseCount()); + } + } + } + + private static final class AndroidDriver implements LevelPlaytestBot.Driver { + private final Instrumentation instrumentation; + private final GameView gameView; + + private AndroidDriver(Instrumentation instrumentation, GameView gameView) { + this.instrumentation = instrumentation; + this.gameView = gameView; + } + + static AndroidDriver from(ActivityScenario activity) { + GameView[] view = new GameView[1]; + activity.onActivity(current -> view[0] = current.gameViewForAutomation()); + return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]); + } + + @Override public void startLevel(int oneBasedLevelNumber) { + instrumentation.runOnMainSync( + () -> gameView.startLevelForAutomation(oneBasedLevelNumber)); + } + + @Override public void setInput(boolean moveLeft, boolean moveRight, boolean jump) { + instrumentation.runOnMainSync( + () -> gameView.setAutomationInput(moveLeft, moveRight, jump)); + } + + @Override public GameView.AutomationSnapshot snapshot() { + GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1]; + instrumentation.runOnMainSync(() -> snapshot[0] = gameView.automationSnapshot()); + return snapshot[0]; + } + + @Override public void waitForNextDecision(long millis) { + try { + Thread.sleep(Math.max(32L, millis)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("Corpse-state bot interrupted", interrupted); + } + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/CorpseStateScenarios.java b/app/src/androidTest/java/com/projectsacrifice/game/CorpseStateScenarios.java new file mode 100644 index 0000000..ed62b56 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/CorpseStateScenarios.java @@ -0,0 +1,93 @@ +package com.projectsacrifice.game; + +/** Intended routes whose solution depends on the transformed corpse state. */ +final class CorpseStateScenarios { + private CorpseStateScenarios() {} + + static LevelPlaytestBot.Scenario levelFifteen() { + return LevelPlaytestBot.Scenario.builder(15, "Level 15 - crushed low step") + .step("Create one crushed body", 4_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1 + && snapshot.firstCorpseCrushed() + && snapshot.firstCorpseWidth() > snapshot.firstCorpseHeight()) + .step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) + .step("Approach the press step", 2_000L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + LevelPlaytestBot.groundedAliveAtOrBeyond(520f))) + .step("Observe a fresh press cycle", 4_000L, + LevelPlaytestBot.Input.none(), + GameView.AutomationSnapshot::firstHazardActive) + .step("Wait for the full rest window", 4_000L, + LevelPlaytestBot.Input.none(), + snapshot -> !snapshot.firstHazardActive()) + .step("Land on the crushed low step", 2_000L, + LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerX() >= 690f + && snapshot.playerY() <= 757f)) + .step("Jump from the step to the ledge", 2_500L, + LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerX() >= 850f + && snapshot.playerY() <= 621f)) + .step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + GameView.AutomationSnapshot::completed)) + .build(); + } + + static LevelPlaytestBot.Scenario levelSixteen() { + return LevelPlaytestBot.Scenario.builder(16, "Level 16 - burned pressure immunity") + .step("Create one burned body on the plate", 4_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1 + && snapshot.firstCorpseBurned() + && !snapshot.firstCorpseActivatesPressure()) + .step("Prove the burned body leaves the bridge dormant", 2_000L, + LevelPlaytestBot.Input.none(), + snapshot -> !snapshot.firstMovingPlatformPowered() + && snapshot.firstMovingPlatformY() >= 800f) + .step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) + .step("Approach the flame before touching its plate", 2_000L, + LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + LevelPlaytestBot.groundedAliveAtOrBeyond(535f))) + .step("Take off over flame and plate", 1_000L, + LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 700f)) + .step("Clear the burned plate", 2_000L, + LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && snapshot.playerX() >= 850f)) + .step("Approach the dormant bridge", 1_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 900f + && !snapshot.firstMovingPlatformPowered()) + .step("Board the dormant bridge", 2_000L, + LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(1, + GameView.AutomationSnapshot::playerOnMovingPlatform)) + .step("Cross to the far floor", 3_000L, + LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && snapshot.playerX() >= 1_450f)) + .step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, + GameView.AutomationSnapshot::completed)) + .build(); + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java index c5c29db..098f294 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java @@ -36,6 +36,7 @@ public final class LevelPaginationTest { List visible = view.visibleLocalizedTextForAutomation(); assertEquals(1, view.levelPageIndexForAutomation()); assertTrue(visible.contains("Steam Charge")); + assertTrue(visible.contains("Ashen Counterweight")); assertFalse(visible.contains("The Volunteer")); assertTrue(visible.contains("Page 2 of 2")); }); diff --git a/app/src/main/java/com/projectsacrifice/game/CorpseManager.java b/app/src/main/java/com/projectsacrifice/game/CorpseManager.java index 567cf9e..a0950f8 100644 --- a/app/src/main/java/com/projectsacrifice/game/CorpseManager.java +++ b/app/src/main/java/com/projectsacrifice/game/CorpseManager.java @@ -9,7 +9,9 @@ public final class CorpseManager { public enum Transformation { PINNED, FROZEN, - CONDUCTIVE + CONDUCTIVE, + BURNED, + CRUSHED } public static final class Body { @@ -24,6 +26,8 @@ public final class CorpseManager { boolean frozen; boolean conductive; boolean pinned; + boolean burned; + boolean crushed; boolean grounded; int supportIndex = -1; @@ -45,6 +49,9 @@ public final class CorpseManager { public boolean frozen() { return frozen; } public boolean conductive() { return conductive; } public boolean pinned() { return pinned; } + public boolean burned() { return burned; } + public boolean crushed() { return crushed; } + public boolean activatesPressure() { return !burned; } public boolean grounded() { return grounded; } public int supportIndex() { return supportIndex; } @@ -148,6 +155,12 @@ public final class CorpseManager { case CONDUCTIVE: body.conductive = true; break; + case BURNED: + body.burned = true; + break; + case CRUSHED: + body.crushed = true; + break; } return true; } diff --git a/app/src/main/java/com/projectsacrifice/game/GameSessionSnapshot.java b/app/src/main/java/com/projectsacrifice/game/GameSessionSnapshot.java index fb35e06..5b89fb5 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameSessionSnapshot.java +++ b/app/src/main/java/com/projectsacrifice/game/GameSessionSnapshot.java @@ -43,7 +43,7 @@ public final class GameSessionSnapshot { public static final class BodyData { private final float x, y, width, height, velocityX, velocityY; - private final boolean frozen, conductive, pinned, grounded; + private final boolean frozen, conductive, pinned, grounded, crushed, burned; private final int supportIndex; private final String deathType; @@ -51,6 +51,15 @@ public final class GameSessionSnapshot { float velocityX, float velocityY, boolean frozen, boolean conductive, boolean pinned, boolean grounded, int supportIndex, String deathType) { + this(x, y, width, height, velocityX, velocityY, frozen, conductive, + pinned, grounded, false, false, supportIndex, deathType); + } + + public BodyData(float x, float y, float width, float height, + float velocityX, float velocityY, boolean frozen, + boolean conductive, boolean pinned, boolean grounded, + boolean crushed, boolean burned, + int supportIndex, String deathType) { this.x = x; this.y = y; this.width = width; @@ -61,6 +70,8 @@ public final class GameSessionSnapshot { this.conductive = conductive; this.pinned = pinned; this.grounded = grounded; + this.crushed = crushed; + this.burned = burned; this.supportIndex = supportIndex; this.deathType = deathType == null ? "SPIKE" : deathType; } @@ -75,6 +86,8 @@ public final class GameSessionSnapshot { public boolean conductive() { return conductive; } public boolean pinned() { return pinned; } public boolean grounded() { return grounded; } + public boolean crushed() { return crushed; } + public boolean burned() { return burned; } public int supportIndex() { return supportIndex; } public String deathType() { return deathType; } } diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 78694d2..7e2675f 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -75,6 +75,11 @@ public final class GameView extends View implements Runnable { private final boolean firstHazardActive; private final float firstHazardX; private final float firstHazardY; + private final boolean firstCorpseBurned; + private final boolean firstCorpseCrushed; + private final boolean firstCorpseActivatesPressure; + private final float firstCorpseWidth; + private final float firstCorpseHeight; AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, boolean visiblyPaused, boolean inputActive, int levelNumber, @@ -163,6 +168,31 @@ public final class GameView extends View implements Runnable { boolean playerOnGround, float playerVelocityX, float playerVelocityY, boolean firstHazardActive, float firstHazardX, float firstHazardY) { + this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, + elapsedMilliseconds, playerX, playerY, deaths, corpseCount, + firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, + firstMovingPlatformPowered, firstMovingPlatformMovingRight, + firstMovingPlatformMovingDown, firstCorpseX, playerOnGround, + playerVelocityX, playerVelocityY, firstHazardActive, + firstHazardX, firstHazardY, false, false, false, + Float.NaN, Float.NaN); + } + + AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, + boolean visiblyPaused, boolean inputActive, int levelNumber, + long elapsedMilliseconds, + float playerX, float playerY, int deaths, int corpseCount, + float firstMovingPlatformX, float firstMovingPlatformY, + boolean playerOnMovingPlatform, + boolean firstMovingPlatformPowered, + boolean firstMovingPlatformMovingRight, + boolean firstMovingPlatformMovingDown, float firstCorpseX, + boolean playerOnGround, float playerVelocityX, + float playerVelocityY, boolean firstHazardActive, + float firstHazardX, float firstHazardY, + boolean firstCorpseBurned, boolean firstCorpseCrushed, + boolean firstCorpseActivatesPressure, + float firstCorpseWidth, float firstCorpseHeight) { this.completed = completed; this.playerAlive = playerAlive; this.allDoorsOpen = allDoorsOpen; @@ -187,6 +217,11 @@ public final class GameView extends View implements Runnable { this.firstHazardActive = firstHazardActive; this.firstHazardX = firstHazardX; this.firstHazardY = firstHazardY; + this.firstCorpseBurned = firstCorpseBurned; + this.firstCorpseCrushed = firstCorpseCrushed; + this.firstCorpseActivatesPressure = firstCorpseActivatesPressure; + this.firstCorpseWidth = firstCorpseWidth; + this.firstCorpseHeight = firstCorpseHeight; } public boolean completed() { return completed; } @@ -213,6 +248,13 @@ public final class GameView extends View implements Runnable { public boolean firstHazardActive() { return firstHazardActive; } public float firstHazardX() { return firstHazardX; } public float firstHazardY() { return firstHazardY; } + public boolean firstCorpseBurned() { return firstCorpseBurned; } + public boolean firstCorpseCrushed() { return firstCorpseCrushed; } + public boolean firstCorpseActivatesPressure() { + return firstCorpseActivatesPressure; + } + public float firstCorpseWidth() { return firstCorpseWidth; } + public float firstCorpseHeight() { return firstCorpseHeight; } } private static final class Player { @@ -593,6 +635,7 @@ public final class GameView extends View implements Runnable { int mass = 0; if (player.alive && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) mass++; for (Body corpse : corpses) { + if (!corpse.activatesPressure()) continue; if (intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) mass++; } return mass >= item.requiredMass; @@ -868,6 +911,16 @@ public final class GameView extends View implements Runnable { corpse.vx = corpse.vy = 0f; corpseManager.transform(corpse, CorpseManager.Transformation.CONDUCTIVE); corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); + } else if (type == DeathType.FIRE) { + corpseManager.transform(corpse, CorpseManager.Transformation.BURNED); + } else if (type == DeathType.CRUSH && source != null) { + corpse.width = 140f; + corpse.height = 24f; + corpse.x = source.box.x - 10f; + corpse.y = source.box.bottom() - corpse.height; + corpse.vx = corpse.vy = 0f; + corpseManager.transform(corpse, CorpseManager.Transformation.CRUSHED); + corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); } } } @@ -1305,6 +1358,19 @@ public final class GameView extends View implements Runnable { } private void drawCorpse(Canvas canvas, Body corpse) { + if (corpse.crushed) { + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE : Color.rgb(126, 93, 58)); + RectF slab = corpseRect(corpse); + canvas.drawRoundRect(slab, 8f, 8f, paint); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.BLACK : Color.rgb(226, 177, 82)); + stroke.setStrokeWidth(5f); + canvas.drawLine(slab.left + 10f, slab.centerY(), + slab.right - 10f, slab.centerY(), stroke); + drawWorldLabel(canvas, "CRUSHED", slab.centerX(), slab.top - 16f); + return; + } canvas.save(); float cx = corpse.x + corpse.width * 0.5f; float cy = corpse.y + corpse.height * 0.5f; @@ -1319,6 +1385,17 @@ public final class GameView extends View implements Runnable { levelElapsed * 1.6f, 1, 0.82f, accessibilitySettings.highContrastEnabled()); canvas.restore(); + if (corpse.burned) { + RectF charred = corpseRect(corpse); + paint.setColor(Color.argb(145, 28, 24, 29)); + canvas.drawRoundRect(charred, 12f, 12f, paint); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE : Color.rgb(255, 138, 54)); + stroke.setStrokeWidth(5f); + canvas.drawLine(charred.left + 10f, charred.bottom - 10f, + charred.right - 10f, charred.top + 10f, stroke); + drawWorldLabel(canvas, "BURNED", charred.centerX(), charred.top - 16f); + } } private void drawHud(Canvas canvas) { @@ -2494,7 +2571,8 @@ public final class GameView extends View implements Runnable { builder.addBody(new GameSessionSnapshot.BodyData( corpse.x, corpse.y, corpse.width, corpse.height, corpse.vx, corpse.vy, corpse.frozen, corpse.conductive, - corpse.pinned, corpse.grounded, corpse.supportIndex, + corpse.pinned, corpse.grounded, corpse.crushed, corpse.burned, + corpse.supportIndex, corpse.deathType())); } ArrayList motionTimes = new ArrayList<>(platformStates.size()); @@ -2579,6 +2657,12 @@ public final class GameView extends View implements Runnable { if (savedBody.pinned()) { corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); } + if (savedBody.crushed()) { + corpseManager.transform(corpse, CorpseManager.Transformation.CRUSHED); + } + if (savedBody.burned()) { + corpseManager.transform(corpse, CorpseManager.Transformation.BURNED); + } } updateSignals(0f, false); @@ -2636,6 +2720,7 @@ public final class GameView extends View implements Runnable { firstHazardX = firstHazardBox.x; firstHazardY = firstHazardBox.y; } + Body firstCorpse = corpses.isEmpty() ? null : corpses.get(0); return new AutomationSnapshot( screen == Screen.COMPLETE, player.alive, @@ -2660,7 +2745,12 @@ public final class GameView extends View implements Runnable { player.vy, firstHazardActive, firstHazardX, - firstHazardY + firstHazardY, + firstCorpse != null && firstCorpse.burned(), + firstCorpse != null && firstCorpse.crushed(), + firstCorpse != null && firstCorpse.activatesPressure(), + firstCorpse == null ? Float.NaN : firstCorpse.width(), + firstCorpse == null ? Float.NaN : firstCorpse.height() ); } diff --git a/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java b/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java index 3848fb2..f5c5e0c 100644 --- a/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java +++ b/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java @@ -14,7 +14,7 @@ public final class VersionedSessionCodec { private static final int MAX_BODIES = 16; private static final int MAX_PLATFORMS = 64; private static final int MAX_MAP_ENTRIES = 128; - public static final int CURRENT_VERSION = 2; + public static final int CURRENT_VERSION = 3; public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT } @@ -41,6 +41,10 @@ public final class VersionedSessionCodec { return encodeWithVersion(snapshot, 1); } + static byte[] encodeLegacyV2(GameSessionSnapshot snapshot) { + return encodeWithVersion(snapshot, 2); + } + static byte[] encodeVersionForTest(int version) { try { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); @@ -75,7 +79,7 @@ public final class VersionedSessionCodec { .player(readPlayer(input)); int bodyCount = checkedCount(input.readInt(), MAX_BODIES); - for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input)); + for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input, version)); int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS); ArrayList motionTimes = new ArrayList<>(platformCount); @@ -108,7 +112,9 @@ public final class VersionedSessionCodec { output.writeFloat(snapshot.respawnSeconds()); writePlayer(output, snapshot.player()); output.writeInt(snapshot.bodies().size()); - for (GameSessionSnapshot.BodyData body : snapshot.bodies()) writeBody(output, body); + for (GameSessionSnapshot.BodyData body : snapshot.bodies()) { + writeBody(output, body, version); + } output.writeInt(snapshot.platformMotionTimes().size()); for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time); writeBooleanMap(output, snapshot.toggleSignals()); @@ -145,7 +151,8 @@ public final class VersionedSessionCodec { input.readInt(), input.readInt()); } - private static void writeBody(DataOutputStream output, GameSessionSnapshot.BodyData body) + private static void writeBody(DataOutputStream output, GameSessionSnapshot.BodyData body, + int version) throws IOException { output.writeFloat(body.x()); output.writeFloat(body.y()); @@ -157,11 +164,15 @@ public final class VersionedSessionCodec { output.writeBoolean(body.conductive()); output.writeBoolean(body.pinned()); output.writeBoolean(body.grounded()); + if (version >= 3) { + output.writeBoolean(body.crushed()); + output.writeBoolean(body.burned()); + } output.writeInt(body.supportIndex()); output.writeUTF(body.deathType()); } - private static GameSessionSnapshot.BodyData readBody(DataInputStream input) + private static GameSessionSnapshot.BodyData readBody(DataInputStream input, int version) throws IOException { float x = finite(input.readFloat()); float y = finite(input.readFloat()); @@ -173,12 +184,14 @@ public final class VersionedSessionCodec { boolean conductive = input.readBoolean(); boolean pinned = input.readBoolean(); boolean grounded = input.readBoolean(); + boolean crushed = version >= 3 && input.readBoolean(); + boolean burned = version >= 3 && input.readBoolean(); int support = input.readInt(); String deathType = input.readUTF(); if (deathType.length() > 24) throw new IllegalArgumentException("Death type too long"); return new GameSessionSnapshot.BodyData(x, y, width, height, velocityX, velocityY, frozen, conductive, pinned, grounded, - support, deathType); + crushed, burned, support, deathType); } private static void writeBooleanMap(DataOutputStream output, Map values) diff --git a/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java b/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java index ff1f607..d2ef57e 100644 --- a/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java +++ b/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java @@ -68,4 +68,34 @@ public final class CorpseManagerTest { CorpseManager.Body foreign = new CorpseManager(1).register("SAW", 0f, 0f); assertFalse(manager.transform(foreign, CorpseManager.Transformation.FROZEN)); } + + @Test + public void burnedBodyRemainsPhysicalButCannotActivatePressureMechanisms() { + CorpseManager manager = new CorpseManager(2); + CorpseManager.Body body = manager.register("FIRE", 10f, 20f); + + assertTrue(manager.transform(body, CorpseManager.Transformation.BURNED)); + + assertTrue(body.burned()); + assertFalse(body.crushed()); + assertFalse(body.activatesPressure()); + assertTrue(manager.bodies().contains(body)); + } + + @Test + public void crushedBodyKeepsOtherTransformationsAndExposesItsLowProfile() { + CorpseManager manager = new CorpseManager(2); + CorpseManager.Body body = manager.register("CRUSH", 10f, 20f) + .size(140f, 24f); + + assertTrue(manager.transform(body, CorpseManager.Transformation.PINNED)); + assertTrue(manager.transform(body, CorpseManager.Transformation.CRUSHED)); + + assertTrue(body.crushed()); + assertFalse(body.burned()); + assertTrue(body.pinned()); + assertTrue(body.activatesPressure()); + assertEquals(140f, body.width(), 0f); + assertEquals(24f, body.height(), 0f); + } } diff --git a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java index 66e079a..ce894c7 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java @@ -12,9 +12,9 @@ import java.util.Set; public final class LevelCatalogTest { @Test - public void productionCatalogContainsFourHazardTeachingLevelsAfterTheVerticalSlice() { + public void productionCatalogContainsHazardsAndTwoCorpseStateTeachingLevels() { List levels = LevelCatalog.all(); - assertEquals(14, levels.size()); + assertEquals(16, levels.size()); Set titles = new HashSet<>(); for (int i = 0; i < levels.size(); i++) { LevelDefinition level = levels.get(i); @@ -26,6 +26,9 @@ public final class LevelCatalogTest { assertTrue(levels.get(11).containsMechanic(LevelDefinition.HazardType.FIRE)); assertTrue(levels.get(12).containsMechanic(LevelDefinition.HazardType.PROJECTILE)); assertTrue(levels.get(13).containsMechanic(LevelDefinition.HazardType.EXPLOSION)); + assertTrue(levels.get(14).containsMechanic(LevelDefinition.HazardType.CRUSHER)); + assertTrue(levels.get(15).containsMechanic(LevelDefinition.HazardType.FIRE)); + assertFalse(levels.get(15).platforms.get(1).controlId.isEmpty()); } @Test diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java index 0cf5f46..ed9df58 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java @@ -8,8 +8,8 @@ import org.junit.Test; public final class LevelPageTest { @Test - public void fourteenLevelsUseTwoBoundedTenCardPages() { - LevelPage page = new LevelPage(14, 10); + public void sixteenLevelsUseTwoBoundedTenCardPages() { + LevelPage page = new LevelPage(16, 10); assertEquals(2, page.pageCount()); assertEquals(0, page.firstIndex()); @@ -19,7 +19,7 @@ public final class LevelPageTest { page.next(); assertEquals(10, page.firstIndex()); - assertEquals(14, page.endExclusive()); + assertEquals(16, page.endExclusive()); assertTrue(page.hasPrevious()); assertFalse(page.hasNext()); @@ -27,7 +27,7 @@ public final class LevelPageTest { assertEquals(1, page.pageIndex()); page.selectIndex(3); assertEquals(0, page.pageIndex()); - page.selectIndex(13); + page.selectIndex(15); assertEquals(1, page.pageIndex()); } } diff --git a/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java b/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java index f5d6807..52b9a38 100644 --- a/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java +++ b/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java @@ -27,6 +27,8 @@ public final class VersionedSessionCodecTest { assertEquals(2, restored.bodies().size()); assertTrue(restored.bodies().get(0).pinned()); assertTrue(restored.bodies().get(1).conductive()); + assertTrue(restored.bodies().get(0).crushed()); + assertTrue(restored.bodies().get(1).burned()); assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes()); assertTrue(restored.toggleSignals().get("gate")); assertEquals(2.25f, restored.timedSignals().get("clock"), 0f); @@ -43,6 +45,19 @@ public final class VersionedSessionCodecTest { decoded.snapshot().saveVersion()); assertTrue(decoded.snapshot().previousSwitchContacts().isEmpty()); assertEquals(2, decoded.snapshot().bodies().size()); + assertFalse(decoded.snapshot().bodies().get(0).crushed()); + assertFalse(decoded.snapshot().bodies().get(1).burned()); + } + + @Test + public void legacyV2MigratesWithSafeDefaultCorpseStates() { + VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode( + VersionedSessionCodec.encodeLegacyV2(sampleSnapshot())); + + assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status()); + assertTrue(decoded.snapshot().previousSwitchContacts().get("gate@420.0")); + assertFalse(decoded.snapshot().bodies().get(0).crushed()); + assertFalse(decoded.snapshot().bodies().get(1).burned()); } @Test @@ -84,10 +99,12 @@ public final class VersionedSessionCodecTest { 0.1f, 0.08f, -1, -1)) .addBody(new GameSessionSnapshot.BodyData( 500f, 826f, 72f, 54f, 0f, 0f, - false, false, true, true, -1, "SPIKE")) + false, false, true, true, true, false, + -1, "CRUSH")) .addBody(new GameSessionSnapshot.BodyData( 720f, 826f, 72f, 54f, 0f, 0f, - false, true, true, true, -1, "ELECTRIC")) + false, true, true, true, false, true, + -1, "FIRE")) .platformMotionTimes(Arrays.asList(1.25f, 3.5f)) .toggleSignal("gate", true) .timedSignal("clock", 2.25f) diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index f7e4674..fd4c785 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -189,7 +189,9 @@ data-driven модель. Завершён первый mechanics slice: product (`crusher`, `fire`, `projectile`, `explosion`), обучающие уровни 11–14, динамическая пагинация каталога и 12/12 bot-прохождений. Последний gate: 99 JVM-тестов и Android 16 matrix `43/43` (`2004.172 s`). Следующая зависимая -итерация — новые corpse states на источниках production hazards. +итерация завершена: `CRUSHED` и `BURNED`, save migration и обучающие уровни +15–16 приняты clean gate `102 JVM` и unfiltered Android matrix `46/46` +(`1,842.248 s`). Доказательства зафиксированы в `STAGE6_CORPSE_STATES.md`. ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней diff --git a/docs/LEVEL_SOLUTION_GRAPHS.md b/docs/LEVEL_SOLUTION_GRAPHS.md index 143f765..50a37f7 100644 --- a/docs/LEVEL_SOLUTION_GRAPHS.md +++ b/docs/LEVEL_SOLUTION_GRAPHS.md @@ -1,8 +1,8 @@ -# Графы решений уровней 1–10 +# Графы решений уровней 1–16 Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения. -На 18 августа 2026 года intended route всех уровней 1–10 автоматически выполнен по три раза подряд без retry masking. `EarlyLevelPlaythroughTest`, `LevelFivePlaythroughTest`, `LateLevelPlaythroughTest` и `LevelEightPlaythroughTest` управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`. +На 19 августа 2026 года intended route всех уровней 1–16 автоматически выполнен по три раза подряд без retry masking. Device-тесты управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`. ## Семантика, общая для графов @@ -11,7 +11,7 @@ - `TIMED` запускает таймер только на фронте контакта; удержание на переключателе не перезапускает истёкший таймер. - `Door.requiredSignals` использует AND-семантику: дверь открыта, только когда активны все указанные сигналы. - Управляемая движущаяся платформа накапливает `motionTime` только при активном `controlId`; при потере сигнала она замирает в текущей фазе `GameMath.pingPong`. -- `SPIKES` оставляет закреплённое тело над шипами; `SAW` передаёт телу `impulseX`/`impulseY`; `FREEZER` создаёт неподвижный блок `104×104`; `ELECTRIC` оставляет закреплённое проводящее тело внутри опасности; `PIT` тела не оставляет. +- `SPIKES` оставляет закреплённое тело над шипами; `SAW` передаёт телу `impulseX`/`impulseY`; `FREEZER` создаёт неподвижный блок `104×104`; `ELECTRIC` оставляет закреплённое проводящее тело внутри опасности; `CRUSHER` создаёт низкое `CRUSHED`-тело; `FIRE` оставляет твёрдое `BURNED`-тело без pressure mass; `PIT` тела не оставляет. - Тела являются твёрдыми опорами для игрока. Смерть сохраняет тела и состояния уровня, respawn возвращает нового игрока в `spawnX`/`spawnY`; restart очищает состояние. - Пересечение живого игрока с `goal` переводит сцену в `COMPLETE`. @@ -360,6 +360,51 @@ Door требует signal "blast-gate" от удалённой PRESSURE доказал перелёт тела за плиту; минимальное authored значение `550` стабильно помещает тело в `firstCorpseX=980`, после чего `allDoorsOpen=true`. +## Уровень 15 — `Pressed Into Service` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `1`; сохранённое тело обязано иметь состояние +`CRUSHED`, размер `140×24` и оставаться твёрдой опорой. + +```text +Верхний ledge начинается на высоте y=720 и недоступен прыжком с floor +→ первый доброволец входит в активный CRUSHER +→ пресс превращает тело в низкую закреплённую плиту 140×24 +→ следующий доброволец наблюдает новый активный цикл и ждёт REST +→ проходит безопасный пресс и приземляется на crushed body +→ отдельным прыжком использует 24 px ступень для достижения ledge +→ проходит по верхней платформе к goal +→ COMPLETE ровно с одной смертью и одним crushed body +``` + +**Контракт:** `CRUSHED` композиционно сохраняется в `CorpseManager`, уменьшает +коллайдер и не удаляет физическую опору. Save version 3 сохраняет флаг; v1/v2 +мигрируют его в `false`. Snapshot отдельно публикует state, размеры и pressure +capability, а Canvas добавляет текстовую метку `CRUSHED` помимо цвета. + +## Уровень 16 — `Ashen Counterweight` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `1`; сохранённое тело обязано иметь состояние +`BURNED`, оставаться твёрдым и не активировать `PRESSURE`. + +```text +Moving bridge над PIT управляется signal "burn-weight" +→ первый доброволец пересекает PRESSURE и погибает в FIRE над плитой +→ burned body остаётся на плите как твёрдый объект +→ burned body не даёт pressure mass: signal остаётся inactive +→ мост остаётся в нижней фазе у y≈840 вместо подъёма +→ следующий доброволец перепрыгивает FIRE и burned body +→ садится на неподвижный мост и пересекает PIT +→ достигает goal на правом floor +→ COMPLETE ровно с одной смертью и одним burned body +``` + +**Контракт:** `BURNED` сохраняет коллайдер, но `activatesPressure=false`; это +проверяется одновременно состоянием тела, неактивным `firstMovingPlatformPowered` +и нижней координатой моста. Save version 3 сохраняет флаг; Canvas добавляет +текстовую метку `BURNED`, поэтому состояние не кодируется только цветом. + ## Проверки, следующие из графов 1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8. diff --git a/docs/STAGE6_CORPSE_STATES.md b/docs/STAGE6_CORPSE_STATES.md new file mode 100644 index 0000000..540ae21 --- /dev/null +++ b/docs/STAGE6_CORPSE_STATES.md @@ -0,0 +1,64 @@ +# Stage 6 production corpse states + +Date: 19 August 2026 + +Beads task: `goon-game-corpse-states` + +## Contract + +The accepted `CorpseManager` transformations now include two production states: + +- `CRUSHED`: a pinned `140×24` solid body whose changed collider creates a low + step without becoming a full-height wall; +- `BURNED`: a solid retained body that no longer contributes pressure mass. + +Both transformations compose with existing body ownership and pinning, have +distinct non-color Canvas labels, are exposed through read-only automation, and +are serialized in active-session save version 3. Legacy version 1 and version 2 +sessions migrate both flags to `false`; progress save versioning is unchanged. + +This is the complete set of new gameplay states approved by the GDD. `IsDestroyed` +is a lifecycle field in the corpse data model, not a listed corpse-state variant; +the plan does not define a separate `Shattered` state. + +Level 15 teaches the collider difference by requiring the crushed slab to reach +an otherwise inaccessible ledge. Level 16 teaches the mechanism difference by +requiring a burned body to remain on a pressure plate without powering the +bridge above a pit. Normal, pinned, frozen and conductive states retain their +accepted teaching routes in levels 2/3/5, 6 and 7/9/10 respectively. + +## TDD evidence + +Focused RED cycles covered the missing transformations and behavior, legacy +codec migration, GameView state publication, catalog growth from 14 to 16, and +both production-input scenarios. Device RED on level 16 showed a small bridge +offset caused by the living volunteer touching the plate before death; the +acceptance was corrected to prove the authored behavior (`powered=false` and a +lower bridge) without requiring an impossible exact initial coordinate. + +The focused runtime integration test creates crushed and burned bodies through +real level hazards. Each playthrough test runs its intended route three times, +with no restart or direct game-state mutation, and requires exactly one death +and one retained body. + +## Acceptance gates + +- JSON validator: `16/16`, format `1`, physics `1`. +- Level 15: `3/3` GREEN in `77.563 s`. +- Level 16: `3/3` GREEN in `32.015 s` after the state-based bridge assertion. +- Runtime state integration: `1/1` GREEN in `1.165 s`. +- Combined corpse-state/hazard/pagination matrix: `8/8` methods GREEN in + `408.438 s` (18 production-input playthroughs plus state and pagination tests). +- Clean gate: `102` JVM tests, failures/errors/skipped `0`; `lintDebug`, + `assembleDebug` and `assembleDebugAndroidTest` GREEN. +- Focused exact-candidate save/restore plus runtime state gate: `3/3` GREEN in + `47.489 s`. +- Final unfiltered Android 16 matrix on the exact clean APK/test APK: `46/46`, + failures `0`, errors `0`, skipped `0`, `1,842.248 s`. +- Debug APK ZIP integrity: GREEN; packaged JSON levels: `16`. +- Package: `com.projectsacrifice.game.debug`, versionCode `9`, minSdk `23`, + targetSdk `34`; APK Signature Scheme v1/v2 verification: GREEN. +- Debug APK SHA-256: + `4984f50aa62203060eb254b69dbf2bdf520ab6cd545eff852d22b10a164bf686`. + +The dedicated AVD was stopped after the final gate. diff --git a/docs/STAGE_REGISTRY.md b/docs/STAGE_REGISTRY.md index f3d7eb7..d62e73d 100644 --- a/docs/STAGE_REGISTRY.md +++ b/docs/STAGE_REGISTRY.md @@ -65,7 +65,7 @@ Snapshot на 19 августа 2026 года: | 3 | 5 | 0 | | 4 | 4 | 0 | | 5 | 15 | 0 | -| 6 | 18 | 12 | +| 6 | 18 | 10 | | 7 | 9 | 9 | | 8 | 1 | 1 | | 9 | 1 | 1 | diff --git a/level-format/src/main/resources/levels/index.json b/level-format/src/main/resources/levels/index.json index df78b25..8d0912a 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"]} +{"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"]} diff --git a/level-format/src/main/resources/levels/level-15.json b/level-format/src/main/resources/levels/level-15.json new file mode 100644 index 0000000..789dfe5 --- /dev/null +++ b/level-format/src/main/resources/levels/level-15.json @@ -0,0 +1,14 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":15, + "copy":{"en":{"title":"Pressed Into Service","hint":"The press leaves a low step. Use its thin profile to reach the ledge."},"ru":{"title":"Служба под прессом","hint":"Пресс оставляет низкую ступень. Используйте её профиль, чтобы достичь уступа."}}, + "world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2180,"y":610,"width":86,"height":110},"targets":{"deaths":1,"seconds":40}, + "platforms":[ + {"box":{"x":0,"y":880,"width":2400,"height":200}}, + {"box":{"x":850,"y":720,"width":1550,"height":160}} + ], + "hazards":[{"type":"CRUSHER","box":{"x":700,"y":690,"width":140,"height":190}, + "impulseX":0,"impulseY":0,"cycleSeconds":4,"activeSeconds":2.5, + "speedX":0,"speedY":0,"visualStyle":"brass-press","audioCue":"crusher-impact"}], + "switches":[],"doors":[] +} diff --git a/level-format/src/main/resources/levels/level-16.json b/level-format/src/main/resources/levels/level-16.json new file mode 100644 index 0000000..657f381 --- /dev/null +++ b/level-format/src/main/resources/levels/level-16.json @@ -0,0 +1,22 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":16, + "copy":{"en":{"title":"Ashen Counterweight","hint":"Ash remains solid but cannot wake the bridge. Use that difference."},"ru":{"title":"Пепельный противовес","hint":"Пепел остаётся твёрдым, но не запускает мост. Используйте это различие."}}, + "world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2200,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":45}, + "platforms":[ + {"box":{"x":0,"y":880,"width":1000,"height":200}}, + {"box":{"x":1000,"y":840,"width":400,"height":40},"travelX":0,"travelY":-300,"periodSeconds":5,"controlId":"burn-weight"}, + {"box":{"x":1400,"y":880,"width":1000,"height":200}} + ], + "hazards":[ + {"type":"FIRE","box":{"x":700,"y":790,"width":120,"height":90}, + "impulseX":0,"impulseY":0,"cycleSeconds":0,"activeSeconds":0, + "speedX":0,"speedY":0,"visualStyle":"blue-flame","audioCue":"fire-burst"}, + {"type":"PIT","box":{"x":1000,"y":880,"width":400,"height":200}, + "impulseX":0,"impulseY":0,"cycleSeconds":0,"activeSeconds":0, + "speedX":0,"speedY":0,"visualStyle":"","audioCue":""} + ], + "switches":[{"type":"PRESSURE","box":{"x":610,"y":860,"width":180,"height":20}, + "signalId":"burn-weight","durationSeconds":0,"requiredMass":1}], + "doors":[] +}