From cc7f487cf7e503e7a4d67a6208b7d032d19881a9 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Wed, 19 Aug 2026 04:07:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20event-driven=20PuzzleSystem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/issues.jsonl | 6 +- README.md | 12 +- .../game/PuzzleSystemIntegrationTest.java | 77 +++++ .../projectsacrifice/game/DoorController.java | 59 ++++ .../com/projectsacrifice/game/GameView.java | 144 ++++---- .../projectsacrifice/game/PuzzleSystem.java | 308 ++++++++++++++++++ .../game/PuzzleSystemTest.java | 153 +++++++++ docs/CURRENT_ROADMAP.md | 9 +- docs/STAGE6_PUZZLE_SYSTEM.md | 63 ++++ 9 files changed, 763 insertions(+), 68 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/PuzzleSystemIntegrationTest.java create mode 100644 app/src/main/java/com/projectsacrifice/game/DoorController.java create mode 100644 app/src/main/java/com/projectsacrifice/game/PuzzleSystem.java create mode 100644 app/src/test/java/com/projectsacrifice/game/PuzzleSystemTest.java create mode 100644 docs/STAGE6_PUZZLE_SYSTEM.md diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a7cfa0b..afe0cb2 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -49,7 +49,7 @@ {"_type":"issue","id":"goon-game-ui-vslice","title":"Базовый HUD и multitouch controls","description":"В APK есть направления, jump, restart, pause, deaths и timer.","acceptance_criteria":"Управление работает одновременно двумя касаниями.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:21Z","close_reason":"Базовый HUD и multitouch controls реализованы.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-ui-vslice","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:22Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-camera-polish","title":"Доработать production camera controller","description":"Добавить vertical damping, room transitions, focus target и управляемый shake.","acceptance_criteria":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","notes":"Stage 5 RC: bounded horizontal/vertical damping, mechanism focus, impact shake, reduced-motion persistence; camera excluded from physics signatures; 35/35 Android gate GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:50Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:45Z","closed_at":"2026-08-18T23:01:45Z","close_reason":"Production camera behavior and reduced-motion acceptance are implemented and regression-tested.","metadata":{"stages":5,"primary_stage":5},"labels":["camera","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-camera-polish","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:16Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-camera-basic","title":"Базовая камера с look-ahead и bounds","description":"В текущем runtime реализовано сглаженное горизонтальное слежение.","acceptance_criteria":"Камера работает во всех 10 уровнях.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:18Z","close_reason":"Базовое сглаженное слежение реализовано.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-camera-basic","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:15Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"goon-game-puzzle-events","title":"Выделить event-driven PuzzleSystem","description":"Заменить прямую проверку сигналов универсальными каналами событий и явными состояниями DoorController.","acceptance_criteria":"Кнопки, двери и платформы связываются данными; integration-тесты проходят.","notes":"2026-08-19: claimed as the next Stage 6 foundation slice after CorpseManager. Planned boundary: deterministic event/signal/door state model, data-driven bindings, GameView adapter, RED-GREEN integration, no level-solution changes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:43:02Z","started_at":"2026-08-19T00:43:02Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","primary-stage-6","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-puzzle-events","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:13Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"goon-game-puzzle-events","title":"Выделить event-driven PuzzleSystem","description":"Заменить прямую проверку сигналов универсальными каналами событий и явными состояниями DoorController.","acceptance_criteria":"Кнопки, двери и платформы связываются данными; integration-тесты проходят.","notes":"2026-08-19: claimed as the next Stage 6 foundation slice after CorpseManager. Planned boundary: deterministic event/signal/door state model, data-driven bindings, GameView adapter, RED-GREEN integration, no level-solution changes.\n2026-08-19: completed test-first. PuzzleSystem owns pressure/toggle/timed/conductive-relay channels, immutable signal/door transitions, explicit AND-based DoorController states, platform control, diagnostics, reset and held-edge-safe save/restore. GameView now submits geometry facts and consumes events; no physics/geometry/solution changes. Final gates: 89 JVM; lint/build GREEN; Android 16 focused 1/1 and unfiltered 38/38, 0 failed/0 skipped, 433.599s; APK ZIP valid, SHA-256 adeed8b3959e2192e15917136e754e626cf06248bbba0dcd7596d55b2685e011. Evidence: docs/STAGE6_PUZZLE_SYSTEM.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T01:07:09Z","started_at":"2026-08-19T00:43:02Z","closed_at":"2026-08-19T01:07:09Z","close_reason":"Acceptance criteria выполнены: кнопки, двери и платформы связаны общей data-driven event model; integration и полная Android-матрица 38/38 зелёные","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","primary-stage-6","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-puzzle-events","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:13Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-puzzle-vslice","title":"Базовые puzzle objects vertical slice","description":"Работают pressure/toggle/timed switches, doors и moving platforms.","acceptance_criteria":"Существующие уровни используют все базовые типы.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:39Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:17Z","close_reason":"Базовые кнопки, двери и платформы реализованы.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-puzzle-vslice","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:11Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-hazards-vslice","title":"Опасности vertical slice","description":"Реализованы spikes, saw, pit, freeze и electricity.","acceptance_criteria":"Опасности присутствуют в каталоге 10 уровней и не ломают restart.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:35Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:16Z","close_reason":"Базовый набор опасностей присутствует в 10 уровнях.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-hazards-vslice","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-corpse-manager","title":"Выделить полноценный CorpseManager","description":"Убрать управление телами из монолитного GameView; добавить register/remove/transform/reset, лимит и диагностические счётчики.","acceptance_criteria":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","notes":"2026-08-19: next Stage 6 foundation slice after character state machine. Planned boundary: deep public corpse lifecycle contract, GameView extraction without level-solution changes, test-first register/remove/transform/reset/limit/diagnostics.\n2026-08-19: completed test-first. CorpseManager now owns bounded register/remove/reset/FIFO eviction, composable pinned/frozen/conductive transformations, body save/restore and immutable diagnostics. GameView remains the physics adapter; no physics, geometry or level-signature changes. Final gates: 84 JVM tests; lintDebug/assembleDebug/assembleDebugAndroidTest GREEN; Android 16 unfiltered 37/37, 0 failed/0 skipped, 427.237s; APK ZIP valid, SHA-256 982fcf312f10bf23feb3a8794b708b61002012a3706c94ebb4320fefb36fb889. Evidence: docs/STAGE6_CORPSE_MANAGER.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:43:01Z","started_at":"2026-08-19T00:19:59Z","closed_at":"2026-08-19T00:43:01Z","close_reason":"Acceptance criteria выполнены: отдельный lifecycle-контракт интегрирован, существующие решения сохранены, полная Android-матрица 37/37 зелёная","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","corpse","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-manager","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:04Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} @@ -69,8 +69,8 @@ {"_type":"issue","id":"goon-game-challenges","title":"Challenges, collectibles и статистика","description":"Реализовать casualty/time/collectible цели и отображение трёх звёзд.","acceptance_criteria":"Расчёт покрыт unit-тестами и сохраняется по LevelID.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","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.","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, тесты и минимум один учебный уровень.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","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":0,"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":"{}"}],"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":"in_progress","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-19T01:07:11Z","started_at":"2026-08-19T01:07:11Z","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-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 8c691d1..a4e608f 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,12 @@ times with identical state signatures. The product owner confirmed completion of external testing on 19 August 2026; the evidence boundary is recorded without invented session data in `docs/STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 5 is accepted. -Stage 6 production work has started. Its first two completed foundation slices add -a deterministic character state machine and a bounded `CorpseManager` that owns -body registration, transformations, eviction, reset, save and restore. Scope and -final evidence are in `docs/STAGE6_CHARACTER_STATES.md` and -`docs/STAGE6_CORPSE_MANAGER.md`; the latest unfiltered gate is -`84 JVM / 37 Android`. +Stage 6 production work has started. Its three completed foundation slices add a +deterministic character state machine, a bounded `CorpseManager`, and an +event-driven `PuzzleSystem` with explicit signal and door transitions. Scope and +final evidence are in `docs/STAGE6_CHARACTER_STATES.md`, +`docs/STAGE6_CORPSE_MANAGER.md`, and `docs/STAGE6_PUZZLE_SYSTEM.md`; the latest +unfiltered gate is `89 JVM / 38 Android`. 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/PuzzleSystemIntegrationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/PuzzleSystemIntegrationTest.java new file mode 100644 index 0000000..36d4c25 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/PuzzleSystemIntegrationTest.java @@ -0,0 +1,77 @@ +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 PuzzleSystemIntegrationTest { + @Test + public void pressureBodyPublishesSignalAndDoorEventsThroughProductionInput() { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + GameView[] view = new GameView[1]; + activity.onActivity(current -> view[0] = current.gameViewForAutomation()); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().run( + new AndroidDriver(instrumentation, view[0]), + EarlyLevelScenarios.levelThree(), 25_000L); + assertTrue(result.diagnostic(), result.completed()); + + PuzzleSystem.Diagnostics[] diagnostics = new PuzzleSystem.Diagnostics[1]; + instrumentation.runOnMainSync( + () -> diagnostics[0] = view[0].puzzleDiagnosticsForAutomation()); + assertTrue(diagnostics[0].updates() > 0L); + assertTrue(diagnostics[0].signalActivatedTransitions() >= 1L); + assertTrue(diagnostics[0].doorOpenedTransitions() >= 1L); + assertEquals(1, diagnostics[0].openDoors()); + } + } + + private static final class AndroidDriver implements LevelPlaytestBot.Driver { + private final Instrumentation instrumentation; + private final GameView view; + + AndroidDriver(Instrumentation instrumentation, GameView view) { + this.instrumentation = instrumentation; + this.view = view; + } + + @Override + public void startLevel(int oneBasedLevelNumber) { + instrumentation.runOnMainSync( + () -> view.startLevelForAutomation(oneBasedLevelNumber)); + } + + @Override + public void setInput(boolean moveLeft, boolean moveRight, boolean jump) { + instrumentation.runOnMainSync( + () -> view.setAutomationInput(moveLeft, moveRight, jump)); + } + + @Override + public GameView.AutomationSnapshot snapshot() { + GameView.AutomationSnapshot[] result = new GameView.AutomationSnapshot[1]; + instrumentation.runOnMainSync(() -> result[0] = view.automationSnapshot()); + return result[0]; + } + + @Override + public void waitForNextDecision(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("Puzzle integration bot interrupted", interrupted); + } + } + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/DoorController.java b/app/src/main/java/com/projectsacrifice/game/DoorController.java new file mode 100644 index 0000000..8abc84c --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/DoorController.java @@ -0,0 +1,59 @@ +package com.projectsacrifice.game; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Explicit state holder for one data-bound puzzle door. */ +public final class DoorController { + public enum State { + CLOSED, + OPEN + } + + private final String id; + private final List requiredSignals; + private State state = State.CLOSED; + + public DoorController(String id, List requiredSignals) { + this.id = requireId(id, "id"); + if (requiredSignals == null) { + throw new IllegalArgumentException("requiredSignals must not be null"); + } + ArrayList validated = new ArrayList<>(requiredSignals.size()); + for (String signal : requiredSignals) { + validated.add(requireId(signal, "required signal")); + } + this.requiredSignals = Collections.unmodifiableList(validated); + } + + public String id() { return id; } + public List requiredSignals() { return requiredSignals; } + public State state() { return state; } + public boolean open() { return state == State.OPEN; } + + boolean update(PuzzleSystem system) { + boolean shouldOpen = true; + for (String signal : requiredSignals) { + if (!system.signalActive(signal)) { + shouldOpen = false; + break; + } + } + State next = shouldOpen ? State.OPEN : State.CLOSED; + if (next == state) return false; + state = next; + return true; + } + + void reset() { + state = State.CLOSED; + } + + private static String requireId(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index c4e733e..e35a10d 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -21,6 +21,7 @@ import android.view.accessibility.AccessibilityEvent; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -228,9 +229,11 @@ public final class GameView extends View implements Runnable { private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES); private final List corpses = corpseManager.bodies(); private final ArrayList platformStates = new ArrayList<>(); - private final Map toggleSignals = new HashMap<>(); - private final Map timedSignals = new HashMap<>(); private final Map previousSwitchContact = new HashMap<>(); + private final Map doorControllers = + new IdentityHashMap<>(); + private final Map doorsById = new HashMap<>(); + private PuzzleSystem puzzleSystem = new PuzzleSystem(); private final AccessibilityStateCache accessibilityStateCache = new AccessibilityStateCache(); private final VolunteerCanvasRenderer volunteerRenderer = new VolunteerCanvasRenderer(); @@ -266,7 +269,6 @@ public final class GameView extends View implements Runnable { private AccessibilitySettings accessibilitySettings; private AudioSettings audioSettings; private float lastFootstepAt = -10f; - private boolean previousAllDoorsOpen; private boolean deterministicAutomationClock; private double deterministicAccumulatorSeconds; private float lastDrawDurationMs; @@ -385,7 +387,6 @@ public final class GameView extends View implements Runnable { cameraShakeY = 0f; cameraController.reset(); lastFootstepAt = -10f; - previousAllDoorsOpen = false; performanceMonitor.reset(); performanceFrameCounter = 0; userPaused = false; @@ -393,9 +394,8 @@ public final class GameView extends View implements Runnable { deriveControls(); corpseManager.reset(); platformStates.clear(); - toggleSignals.clear(); - timedSignals.clear(); previousSwitchContact.clear(); + configurePuzzleSystem(); completionResult = null; configureAccessibilitySignals(); invalidateHudTextCache(); @@ -451,17 +451,6 @@ public final class GameView extends View implements Runnable { lastFootstepAt = levelElapsed; audioEngine.play(AudioCue.FOOTSTEP); } - boolean allDoorsOpen = allDoorsOpenNow(); - if (allDoorsOpen && !previousAllDoorsOpen) { - audioEngine.play(AudioCue.DOOR); - if (!level.doors.isEmpty()) { - LevelDefinition.Door door = level.doors.get(0); - cameraController.requestFocus(door.box.x + door.box.width * 0.5f, - door.box.y + door.box.height * 0.5f, 0.35f); - } - } - previousAllDoorsOpen = allDoorsOpen; - refreshAccessibilityDescriptionIfStateChanged(); CameraController.Frame camera = cameraController.update(dt, @@ -473,30 +462,85 @@ public final class GameView extends View implements Runnable { cameraShakeY = camera.shakeY(); } - private void updateSignals(float dt) { - for (Map.Entry item : new ArrayList<>(timedSignals.entrySet())) { - timedSignals.put(item.getKey(), Math.max(0f, item.getValue() - dt)); + private void configurePuzzleSystem() { + doorControllers.clear(); + doorsById.clear(); + ArrayList controllers = new ArrayList<>(level.doors.size()); + for (int index = 0; index < level.doors.size(); index++) { + LevelDefinition.Door door = level.doors.get(index); + String id = "door-" + index; + DoorController controller = new DoorController(id, door.requiredSignals); + controllers.add(controller); + doorControllers.put(door, controller); + doorsById.put(id, door); } + puzzleSystem = new PuzzleSystem(controllers); + } + private void updateSignals(float dt) { + updateSignals(dt, true); + } + + private void updateSignals(float dt, boolean feedback) { + ArrayList inputs = new ArrayList<>( + level.switches.size() + level.hazards.size()); for (LevelDefinition.Switch item : level.switches) { boolean contact = switchContact(item); String contactKey = item.signalId + "@" + item.box.x; boolean previous = Boolean.TRUE.equals(previousSwitchContact.get(contactKey)); - if (item.type == LevelDefinition.SwitchType.TOGGLE && contact && !previous) { - toggleSignals.put(item.signalId, !Boolean.TRUE.equals(toggleSignals.get(item.signalId))); + switch (item.type) { + case PRESSURE: + inputs.add(PuzzleSystem.Input.pressure( + contactKey, item.signalId, contact)); + break; + case TOGGLE: + inputs.add(PuzzleSystem.Input.toggle( + contactKey, item.signalId, contact)); + break; + case TIMED: + inputs.add(PuzzleSystem.Input.timed( + contactKey, item.signalId, contact, item.durationSeconds)); + break; + } + if (feedback && contact && !previous + && item.type != LevelDefinition.SwitchType.PRESSURE) { emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } - if (item.type == LevelDefinition.SwitchType.TIMED && contact && !previous) { - timedSignals.put(item.signalId, item.durationSeconds); - emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); - } - if (contact && !previous) audioEngine.play(AudioCue.SWITCH); - if (contact && !previous && player.alive + if (feedback && contact && !previous) audioEngine.play(AudioCue.SWITCH); + if (feedback && contact && !previous && player.alive && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) { characterStateMachine.beginInteraction(); } previousSwitchContact.put(contactKey, contact); } + + for (int index = 0; index < level.hazards.size(); index++) { + LevelDefinition.Hazard hazard = level.hazards.get(index); + if (hazard.relayId == null || hazard.relayId.isEmpty()) continue; + boolean active = false; + for (Body corpse : corpses) { + if (corpse.conductive + && intersects(corpse.x, corpse.y, corpse.width, corpse.height, + hazard.box)) { + active = true; + break; + } + } + inputs.add(PuzzleSystem.Input.relay( + "relay-" + index, hazard.relayId, active)); + } + + puzzleSystem.update(dt, inputs); + if (!feedback) return; + for (PuzzleSystem.Event event : puzzleSystem.events()) { + if (event.kind() != PuzzleSystem.Event.Kind.DOOR_OPENED) continue; + audioEngine.play(AudioCue.DOOR); + LevelDefinition.Door door = doorsById.get(event.targetId()); + if (door != null) { + cameraController.requestFocus(door.box.x + door.box.width * 0.5f, + door.box.y + door.box.height * 0.5f, 0.35f); + } + } } private boolean allDoorsOpenNow() { @@ -515,28 +559,12 @@ public final class GameView extends View implements Runnable { } private boolean signalActive(String id) { - if (id == null || id.isEmpty()) return true; - if (Boolean.TRUE.equals(toggleSignals.get(id))) return true; - Float time = timedSignals.get(id); - if (time != null && time > 0f) return true; - for (LevelDefinition.Switch item : level.switches) { - if (item.signalId.equals(id) && item.type == LevelDefinition.SwitchType.PRESSURE && switchContact(item)) return true; - } - for (LevelDefinition.Hazard hazard : level.hazards) { - if (id.equals(hazard.relayId)) { - for (Body corpse : corpses) { - if (corpse.conductive && intersects(corpse.x, corpse.y, corpse.width, corpse.height, hazard.box)) return true; - } - } - } - return false; + return puzzleSystem.signalActive(id); } private boolean doorOpen(LevelDefinition.Door door) { - for (String signal : door.requiredSignals) { - if (!signalActive(signal)) return false; - } - return true; + DoorController controller = doorControllers.get(door); + return controller != null && controller.open(); } private void updatePlatforms(float dt) { @@ -1003,8 +1031,9 @@ public final class GameView extends View implements Runnable { stroke.setStrokeWidth(7f); canvas.drawLine(box.centerX(), box.centerY(), box.centerX() + (active ? 20 : -20), box.top - 28, stroke); if (item.type == LevelDefinition.SwitchType.TIMED && active) { - Float remaining = timedSignals.get(item.signalId); - drawWorldLabel(canvas, String.format(Locale.US, "%.1f", remaining == null ? 0f : remaining), box.centerX(), box.top - 52); + float remaining = puzzleSystem.remainingSeconds(item.signalId); + drawWorldLabel(canvas, String.format(Locale.US, "%.1f", remaining), + box.centerX(), box.top - 52); } drawWorldLabel(canvas, active ? "ON" : "OFF", box.centerX(), box.bottom + 32); } @@ -2282,10 +2311,11 @@ public final class GameView extends View implements Runnable { ArrayList motionTimes = new ArrayList<>(platformStates.size()); for (PlatformState state : platformStates) motionTimes.add(state.motionTime); builder.platformMotionTimes(motionTimes); - for (Map.Entry entry : toggleSignals.entrySet()) { + PuzzleSystem.State puzzleState = puzzleSystem.snapshot(); + for (Map.Entry entry : puzzleState.toggleSignals().entrySet()) { builder.toggleSignal(entry.getKey(), Boolean.TRUE.equals(entry.getValue())); } - for (Map.Entry entry : timedSignals.entrySet()) { + for (Map.Entry entry : puzzleState.timedSignals().entrySet()) { builder.timedSignal(entry.getKey(), Math.max(0f, entry.getValue())); } for (Map.Entry entry : previousSwitchContact.entrySet()) { @@ -2316,12 +2346,10 @@ public final class GameView extends View implements Runnable { levelElapsed = snapshot.elapsedSeconds(); deaths = snapshot.deaths(); respawnTimer = snapshot.respawnSeconds(); - toggleSignals.clear(); - toggleSignals.putAll(snapshot.toggleSignals()); - timedSignals.clear(); - timedSignals.putAll(snapshot.timedSignals()); previousSwitchContact.clear(); previousSwitchContact.putAll(snapshot.previousSwitchContacts()); + puzzleSystem.restore(snapshot.toggleSignals(), snapshot.timedSignals(), + snapshot.previousSwitchContacts()); for (int i = 0; i < platformStates.size(); i++) { PlatformState state = platformStates.get(i); state.motionTime = snapshot.platformMotionTimes().get(i); @@ -2362,6 +2390,7 @@ public final class GameView extends View implements Runnable { corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); } } + updateSignals(0f, false); leftPressed = false; rightPressed = false; @@ -2370,7 +2399,6 @@ public final class GameView extends View implements Runnable { userPaused = true; paused = false; lastFootstepAt = levelElapsed; - previousAllDoorsOpen = allDoorsOpenNow(); cameraController.reset(); cameraX = cameraY = cameraShakeX = cameraShakeY = 0f; lastFrameNanos = System.nanoTime(); @@ -2448,4 +2476,8 @@ public final class GameView extends View implements Runnable { public CorpseManager.Diagnostics corpseDiagnosticsForAutomation() { return corpseManager.diagnostics(); } + + public PuzzleSystem.Diagnostics puzzleDiagnosticsForAutomation() { + return puzzleSystem.diagnostics(); + } } diff --git a/app/src/main/java/com/projectsacrifice/game/PuzzleSystem.java b/app/src/main/java/com/projectsacrifice/game/PuzzleSystem.java new file mode 100644 index 0000000..fe08da6 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/PuzzleSystem.java @@ -0,0 +1,308 @@ +package com.projectsacrifice.game; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Deterministic signal-channel state updated from one frame of puzzle inputs. */ +public final class PuzzleSystem { + public enum SourceKind { + TOGGLE, + PRESSURE, + TIMED, + RELAY + } + + public static final class Input { + private final String sourceId; + private final String channelId; + private final SourceKind kind; + private final boolean active; + private final float durationSeconds; + + private Input(String sourceId, String channelId, SourceKind kind, boolean active, + float durationSeconds) { + this.sourceId = requireId(sourceId, "sourceId"); + this.channelId = requireId(channelId, "channelId"); + this.kind = kind; + this.active = active; + this.durationSeconds = durationSeconds; + } + + public static Input toggle(String sourceId, String channelId, boolean active) { + return new Input(sourceId, channelId, SourceKind.TOGGLE, active, 0f); + } + + public static Input pressure(String sourceId, String channelId, boolean active) { + return new Input(sourceId, channelId, SourceKind.PRESSURE, active, 0f); + } + + public static Input timed(String sourceId, String channelId, boolean active, + float durationSeconds) { + if (durationSeconds <= 0f) { + throw new IllegalArgumentException("durationSeconds must be positive"); + } + return new Input(sourceId, channelId, SourceKind.TIMED, active, durationSeconds); + } + + public static Input relay(String sourceId, String channelId, boolean active) { + return new Input(sourceId, channelId, SourceKind.RELAY, active, 0f); + } + } + + public static final class Event { + public enum Kind { + SIGNAL_ACTIVATED, + SIGNAL_DEACTIVATED, + DOOR_OPENED, + DOOR_CLOSED + } + + private final Kind kind; + private final String targetId; + + private Event(Kind kind, String targetId) { + this.kind = kind; + this.targetId = targetId; + } + + public Kind kind() { return kind; } + public String channelId() { return targetId; } + public String targetId() { return targetId; } + } + + public static final class State { + private final Map toggleSignals; + private final Map timedSignals; + + private State(Map toggleSignals, + Map timedSignals) { + this.toggleSignals = Collections.unmodifiableMap( + new HashMap<>(toggleSignals)); + this.timedSignals = Collections.unmodifiableMap( + new HashMap<>(timedSignals)); + } + + public Map toggleSignals() { return toggleSignals; } + public Map timedSignals() { return timedSignals; } + } + + public static final class Diagnostics { + private final long updates; + private final long signalActivatedTransitions; + private final long signalDeactivatedTransitions; + private final long doorOpenedTransitions; + private final long doorClosedTransitions; + private final int activeSignals; + private final int openDoors; + + private Diagnostics(long updates, long signalActivatedTransitions, + long signalDeactivatedTransitions, long doorOpenedTransitions, + long doorClosedTransitions, int activeSignals, int openDoors) { + this.updates = updates; + this.signalActivatedTransitions = signalActivatedTransitions; + this.signalDeactivatedTransitions = signalDeactivatedTransitions; + this.doorOpenedTransitions = doorOpenedTransitions; + this.doorClosedTransitions = doorClosedTransitions; + this.activeSignals = activeSignals; + this.openDoors = openDoors; + } + + public long updates() { return updates; } + public long signalActivatedTransitions() { return signalActivatedTransitions; } + public long signalDeactivatedTransitions() { return signalDeactivatedTransitions; } + public long doorOpenedTransitions() { return doorOpenedTransitions; } + public long doorClosedTransitions() { return doorClosedTransitions; } + public int activeSignals() { return activeSignals; } + public int openDoors() { return openDoors; } + } + + private final Map toggleChannels = new HashMap<>(); + private final Map timedChannels = new HashMap<>(); + private final Map previousSourceActivity = new HashMap<>(); + private final Map sourceKinds = new HashMap<>(); + private final Map sourceChannels = new HashMap<>(); + private final Set knownChannels = new LinkedHashSet<>(); + private final List doors; + private List events = Collections.emptyList(); + private long updates; + private long signalActivatedTransitions; + private long signalDeactivatedTransitions; + private long doorOpenedTransitions; + private long doorClosedTransitions; + + public PuzzleSystem() { + this(Collections.emptyList()); + } + + public PuzzleSystem(List doors) { + if (doors == null) throw new IllegalArgumentException("doors must not be null"); + ArrayList validated = new ArrayList<>(doors.size()); + LinkedHashSet ids = new LinkedHashSet<>(); + for (DoorController door : doors) { + if (door == null) throw new IllegalArgumentException("door must not be null"); + if (!ids.add(door.id())) throw new IllegalArgumentException("duplicate door " + door.id()); + validated.add(door); + } + this.doors = Collections.unmodifiableList(validated); + } + + public void update(float deltaSeconds, List inputs) { + if (deltaSeconds < 0f) throw new IllegalArgumentException("deltaSeconds must not be negative"); + if (inputs == null) throw new IllegalArgumentException("inputs must not be null"); + updates++; + + LinkedHashSet affected = new LinkedHashSet<>(knownChannels); + for (Input input : inputs) { + if (input == null) throw new IllegalArgumentException("input must not be null"); + affected.add(input.channelId); + } + + Map before = new HashMap<>(); + for (String channelId : affected) before.put(channelId, signalActive(channelId)); + + for (Map.Entry timer : new ArrayList<>(timedChannels.entrySet())) { + timedChannels.put(timer.getKey(), + Math.max(0f, timer.getValue() - deltaSeconds)); + } + + for (Input input : inputs) { + knownChannels.add(input.channelId); + boolean previous = Boolean.TRUE.equals(previousSourceActivity.get(input.sourceId)); + if (input.kind == SourceKind.TOGGLE && input.active && !previous) { + toggleChannels.put(input.channelId, + !Boolean.TRUE.equals(toggleChannels.get(input.channelId))); + } + if (input.kind == SourceKind.TIMED && input.active && !previous) { + timedChannels.put(input.channelId, input.durationSeconds); + } + sourceKinds.put(input.sourceId, input.kind); + sourceChannels.put(input.sourceId, input.channelId); + previousSourceActivity.put(input.sourceId, input.active); + } + + ArrayList nextEvents = new ArrayList<>(); + for (String channelId : affected) { + boolean wasActive = Boolean.TRUE.equals(before.get(channelId)); + boolean isActive = signalActive(channelId); + if (wasActive != isActive) { + nextEvents.add(new Event(isActive + ? Event.Kind.SIGNAL_ACTIVATED + : Event.Kind.SIGNAL_DEACTIVATED, channelId)); + if (isActive) signalActivatedTransitions++; + else signalDeactivatedTransitions++; + } + } + for (DoorController door : doors) { + if (door.update(this)) { + nextEvents.add(new Event(door.open() + ? Event.Kind.DOOR_OPENED + : Event.Kind.DOOR_CLOSED, door.id())); + if (door.open()) doorOpenedTransitions++; + else doorClosedTransitions++; + } + } + events = Collections.unmodifiableList(nextEvents); + } + + public boolean signalActive(String channelId) { + if (channelId == null || channelId.isEmpty()) return true; + if (Boolean.TRUE.equals(toggleChannels.get(channelId))) return true; + if (remainingSeconds(channelId) > 0f) return true; + for (Map.Entry source : sourceChannels.entrySet()) { + if (channelId.equals(source.getValue()) + && (sourceKinds.get(source.getKey()) == SourceKind.PRESSURE + || sourceKinds.get(source.getKey()) == SourceKind.RELAY) + && Boolean.TRUE.equals(previousSourceActivity.get(source.getKey()))) { + return true; + } + } + return false; + } + + public float remainingSeconds(String channelId) { + Float remaining = timedChannels.get(channelId); + return remaining == null ? 0f : Math.max(0f, remaining); + } + + public List events() { + return events; + } + + public Diagnostics diagnostics() { + int activeSignals = 0; + for (String channelId : knownChannels) { + if (signalActive(channelId)) activeSignals++; + } + int openDoors = 0; + for (DoorController door : doors) { + if (door.open()) openDoors++; + } + return new Diagnostics(updates, signalActivatedTransitions, + signalDeactivatedTransitions, doorOpenedTransitions, + doorClosedTransitions, activeSignals, openDoors); + } + + public State snapshot() { + return new State(toggleChannels, timedChannels); + } + + public void restore(State state) { + if (state == null) throw new IllegalArgumentException("state must not be null"); + restore(state.toggleSignals(), state.timedSignals()); + } + + public void restore(State state, Map sourceActivity) { + if (state == null) throw new IllegalArgumentException("state must not be null"); + restore(state.toggleSignals(), state.timedSignals(), sourceActivity); + } + + public void restore(Map toggles, Map timers) { + restore(toggles, timers, Collections.emptyMap()); + } + + public void restore(Map toggles, Map timers, + Map sourceActivity) { + if (toggles == null || timers == null) { + throw new IllegalArgumentException("puzzle state maps must not be null"); + } + if (sourceActivity == null) { + throw new IllegalArgumentException("sourceActivity must not be null"); + } + reset(); + toggleChannels.putAll(toggles); + timedChannels.putAll(timers); + previousSourceActivity.putAll(sourceActivity); + knownChannels.addAll(toggles.keySet()); + knownChannels.addAll(timers.keySet()); + for (DoorController door : doors) door.update(this); + events = Collections.emptyList(); + } + + public void reset() { + toggleChannels.clear(); + timedChannels.clear(); + previousSourceActivity.clear(); + sourceKinds.clear(); + sourceChannels.clear(); + knownChannels.clear(); + for (DoorController door : doors) door.reset(); + events = Collections.emptyList(); + updates = 0L; + signalActivatedTransitions = 0L; + signalDeactivatedTransitions = 0L; + doorOpenedTransitions = 0L; + doorClosedTransitions = 0L; + } + + private static String requireId(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/PuzzleSystemTest.java b/app/src/test/java/com/projectsacrifice/game/PuzzleSystemTest.java new file mode 100644 index 0000000..a11c309 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/PuzzleSystemTest.java @@ -0,0 +1,153 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.Arrays; + +import org.junit.Test; + +public final class PuzzleSystemTest { + @Test + public void toggleChannelChangesOnlyOnRisingEdgesAndPublishesEvents() { + PuzzleSystem system = new PuzzleSystem(); + PuzzleSystem.Input held = PuzzleSystem.Input.toggle("lever-1", "gate", true); + + system.update(0.016f, Collections.singletonList(held)); + assertTrue(system.signalActive("gate")); + assertEquals(PuzzleSystem.Event.Kind.SIGNAL_ACTIVATED, + system.events().get(0).kind()); + assertEquals("gate", system.events().get(0).channelId()); + + system.update(0.016f, Collections.singletonList(held)); + assertTrue(system.signalActive("gate")); + assertTrue(system.events().isEmpty()); + + system.update(0.016f, Collections.singletonList( + PuzzleSystem.Input.toggle("lever-1", "gate", false))); + system.update(0.016f, Collections.singletonList(held)); + assertFalse(system.signalActive("gate")); + assertEquals(PuzzleSystem.Event.Kind.SIGNAL_DEACTIVATED, + system.events().get(0).kind()); + } + + @Test + public void pressureChannelAggregatesEveryActiveSourceWithoutFlicker() { + PuzzleSystem system = new PuzzleSystem(); + + system.update(0.016f, Arrays.asList( + PuzzleSystem.Input.pressure("plate-left", "weight", true), + PuzzleSystem.Input.pressure("plate-right", "weight", false))); + assertTrue(system.signalActive("weight")); + assertEquals(1, system.events().size()); + + system.update(0.016f, Arrays.asList( + PuzzleSystem.Input.pressure("plate-left", "weight", false), + PuzzleSystem.Input.pressure("plate-right", "weight", true))); + assertTrue(system.signalActive("weight")); + assertTrue(system.events().isEmpty()); + + system.update(0.016f, Arrays.asList( + PuzzleSystem.Input.pressure("plate-left", "weight", false), + PuzzleSystem.Input.pressure("plate-right", "weight", false))); + assertFalse(system.signalActive("weight")); + assertEquals(PuzzleSystem.Event.Kind.SIGNAL_DEACTIVATED, + system.events().get(0).kind()); + } + + @Test + public void timedChannelStartsOnRisingEdgeExpiresAndCanBeRearmed() { + PuzzleSystem system = new PuzzleSystem(); + + system.update(0f, Collections.singletonList( + PuzzleSystem.Input.timed("clock-switch", "clock", true, 0.5f))); + assertTrue(system.signalActive("clock")); + assertEquals(0.5f, system.remainingSeconds("clock"), 0f); + + system.update(0.2f, Collections.singletonList( + PuzzleSystem.Input.timed("clock-switch", "clock", true, 0.5f))); + assertEquals(0.3f, system.remainingSeconds("clock"), 0.0001f); + assertTrue(system.events().isEmpty()); + + system.update(0.31f, Collections.singletonList( + PuzzleSystem.Input.timed("clock-switch", "clock", false, 0.5f))); + assertFalse(system.signalActive("clock")); + assertEquals(PuzzleSystem.Event.Kind.SIGNAL_DEACTIVATED, + system.events().get(0).kind()); + + system.update(0f, Collections.singletonList( + PuzzleSystem.Input.timed("clock-switch", "clock", true, 0.5f))); + assertTrue(system.signalActive("clock")); + assertEquals(0.5f, system.remainingSeconds("clock"), 0f); + } + + @Test + public void andDoorPublishesExplicitOpenAndCloseTransitions() { + DoorController exit = new DoorController("exit", + Arrays.asList("weight", "power")); + PuzzleSystem system = new PuzzleSystem(Collections.singletonList(exit)); + + system.update(0.016f, Arrays.asList( + PuzzleSystem.Input.pressure("plate", "weight", true), + PuzzleSystem.Input.relay("conductor", "power", false))); + assertEquals(DoorController.State.CLOSED, exit.state()); + + system.update(0.016f, Arrays.asList( + PuzzleSystem.Input.pressure("plate", "weight", true), + PuzzleSystem.Input.relay("conductor", "power", true))); + assertEquals(DoorController.State.OPEN, exit.state()); + assertTrue(hasEvent(system, PuzzleSystem.Event.Kind.DOOR_OPENED, "exit")); + + system.update(0.016f, Arrays.asList( + PuzzleSystem.Input.pressure("plate", "weight", false), + PuzzleSystem.Input.relay("conductor", "power", true))); + assertEquals(DoorController.State.CLOSED, exit.state()); + assertTrue(hasEvent(system, PuzzleSystem.Event.Kind.DOOR_CLOSED, "exit")); + } + + @Test + public void resetAndRestoreOwnAnImmutablePuzzleStateSnapshot() { + DoorController exit = new DoorController("exit", Arrays.asList("gate", "clock")); + PuzzleSystem source = new PuzzleSystem(Collections.singletonList(exit)); + source.update(0f, Arrays.asList( + PuzzleSystem.Input.toggle("lever", "gate", true), + PuzzleSystem.Input.timed("timer", "clock", true, 2f))); + PuzzleSystem.State saved = source.snapshot(); + + source.reset(); + assertFalse(source.signalActive("gate")); + assertFalse(source.signalActive("clock")); + assertEquals(DoorController.State.CLOSED, exit.state()); + + DoorController restoredDoor = new DoorController("exit", + Arrays.asList("gate", "clock")); + PuzzleSystem restored = new PuzzleSystem(Collections.singletonList(restoredDoor)); + restored.restore(saved, Collections.singletonMap("lever", true)); + + assertTrue(restored.signalActive("gate")); + assertEquals(2f, restored.remainingSeconds("clock"), 0f); + assertEquals(DoorController.State.OPEN, restoredDoor.state()); + restored.update(0f, Collections.singletonList( + PuzzleSystem.Input.toggle("lever", "gate", true))); + assertTrue("Held restored contact must not toggle again", + restored.signalActive("gate")); + assertEquals(Boolean.TRUE, saved.toggleSignals().get("gate")); + assertEquals(2f, saved.timedSignals().get("clock"), 0f); + try { + saved.toggleSignals().put("other", true); + throw new AssertionError("Puzzle state maps must be immutable"); + } catch (UnsupportedOperationException expected) { + // Contract proven. + } + } + + private static boolean hasEvent(PuzzleSystem system, PuzzleSystem.Event.Kind kind, + String targetId) { + for (PuzzleSystem.Event event : system.events()) { + if (event.kind() == kind && targetId.equals(event.targetId())) return true; + } + return false; + } +} diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index 476f32c..73f3a30 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -179,12 +179,15 @@ baseline-задачи также имели явного владельца по - принят `goon-game-milestone-production`. Статус на 19 августа 2026: Stage 6 начат в изолированном production stream. -Завершены первые два foundation slice. `goon-game-character-states` добавил +Завершены три foundation slice. `goon-game-character-states` добавил явные Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions. `goon-game-corpse-manager` перенёс регистрацию, transforms, FIFO budget, reset, save/restore и diagnostics тел в отдельного владельца без изменения физики и -геометрии. Последний gate: 84 JVM-теста и Android 16 matrix `37/37`. -Следующий foundation slice — event-driven `PuzzleSystem`. +геометрии. `goon-game-puzzle-events` выделил pressure/toggle/timed/relay channels, +immutable events и явные DoorController states; двери и платформы читают общую +data-driven модель. Последний gate: 89 JVM-тестов и Android 16 matrix `38/38`. +Следующая итерация — production-каталог hazards (`crusher`, `fire`, projectile, +explosion), который затем даёт источники для новых corpse states. ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней diff --git a/docs/STAGE6_PUZZLE_SYSTEM.md b/docs/STAGE6_PUZZLE_SYSTEM.md new file mode 100644 index 0000000..49bafa9 --- /dev/null +++ b/docs/STAGE6_PUZZLE_SYSTEM.md @@ -0,0 +1,63 @@ +# Stage 6 event-driven PuzzleSystem + +Date: 19 August 2026 + +Beads task: `goon-game-puzzle-events` + +## Contract + +`PuzzleSystem` is the deterministic owner of puzzle signal channels. Each frame, +`GameView` submits data-bound source facts and consumes published transitions: + +- pressure and conductive-relay sources aggregate by channel; +- toggle and timed sources react only to their own rising contact edge; +- timed channels expire deterministically and can be rearmed; +- `DoorController` has an explicit `CLOSED` or `OPEN` state and ANDs its required + channels; +- controlled platforms read the same channel state as doors and rendering; +- signal and door open/close transitions are immutable frame events; +- diagnostics expose update and transition counts without mutating gameplay. + +`GameView` remains the geometry adapter: it detects player/body contact and +conductive-body overlap, but it no longer owns toggle/timer maps or calculates a +door's required signals. Door audio and camera focus consume `DOOR_OPENED` rather +than comparing an ad-hoc previous aggregate. + +The existing JSON `signalId`, `requiredSignals`, `controlId`, duration, and mass +bindings remain unchanged. Existing content validation still rejects missing +sources before packaging. + +## Lifecycle + +Puzzle state has an immutable snapshot containing toggle and timed channels. +Reset clears channels, source edges, door states, events, and diagnostics. Restore +also seeds held-source edge activity, so recreating an Activity while standing on +a toggle or timer does not activate it a second time. Pressure and relay state is +recomputed from restored bodies and geometry without user feedback side effects. + +## TDD evidence + +Separate RED cycles covered the missing base contract, pressure aggregation, +timed expiry/rearm, relay plus explicit AND-door transitions, immutable +reset/save/restore, restored held-source edges, diagnostics, and the missing +`GameView` integration hook. Every RED was followed by its focused GREEN before +the next behavior was added. + +The focused Android test completes the level-3 production-input route, leaves the +saw body on its pressure plate, and verifies an activated signal, a published +door-open transition, and one open `DoorController` through read-only diagnostics. + +## Final gates + +- JSON validator: `10/10`, format `1`, physics `1`. +- JVM: `89`, failures `0`, errors `0`, skipped `0`. +- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN. +- focused Android 16 pressure/door integration: `1/1` GREEN. +- final unfiltered Android 16 matrix: `38/38`, failures `0`, errors `0`, + skipped `0`, `433.599 s`, on the exact final APK/test APK. +- debug APK ZIP integrity: `unzip -t` GREEN. +- debug APK SHA-256: + `adeed8b3959e2192e15917136e754e626cf06248bbba0dcd7596d55b2685e011`. + +The dedicated AVD was stopped after the final gate. No movement, collision, +physics version, level geometry, or accepted solution signature changed.