Выделить event-driven PuzzleSystem

This commit is contained in:
2026-08-19 04:07:29 +03:00
parent cc3b5902cc
commit cc7f487cf7
9 changed files with 763 additions and 68 deletions
+3 -3
View File
@@ -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-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-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-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-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-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} {"_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-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-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-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-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":"{}"}],"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":"{}"},{"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-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-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} {"_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}
+6 -6
View File
@@ -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 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. 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 Stage 6 production work has started. Its three completed foundation slices add a
a deterministic character state machine and a bounded `CorpseManager` that owns deterministic character state machine, a bounded `CorpseManager`, and an
body registration, transformations, eviction, reset, save and restore. Scope and event-driven `PuzzleSystem` with explicit signal and door transitions. Scope and
final evidence are in `docs/STAGE6_CHARACTER_STATES.md` and final evidence are in `docs/STAGE6_CHARACTER_STATES.md`,
`docs/STAGE6_CORPSE_MANAGER.md`; the latest unfiltered gate is `docs/STAGE6_CORPSE_MANAGER.md`, and `docs/STAGE6_PUZZLE_SYSTEM.md`; the latest
`84 JVM / 37 Android`. 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: 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:
@@ -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<MainActivity> 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);
}
}
}
}
@@ -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<String> requiredSignals;
private State state = State.CLOSED;
public DoorController(String id, List<String> requiredSignals) {
this.id = requireId(id, "id");
if (requiredSignals == null) {
throw new IllegalArgumentException("requiredSignals must not be null");
}
ArrayList<String> 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<String> 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;
}
}
@@ -21,6 +21,7 @@ import android.view.accessibility.AccessibilityEvent;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; 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 CorpseManager corpseManager = new CorpseManager(MAX_CORPSES);
private final List<Body> corpses = corpseManager.bodies(); private final List<Body> corpses = corpseManager.bodies();
private final ArrayList<PlatformState> platformStates = new ArrayList<>(); private final ArrayList<PlatformState> platformStates = new ArrayList<>();
private final Map<String, Boolean> toggleSignals = new HashMap<>();
private final Map<String, Float> timedSignals = new HashMap<>();
private final Map<String, Boolean> previousSwitchContact = new HashMap<>(); private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
private final Map<LevelDefinition.Door, DoorController> doorControllers =
new IdentityHashMap<>();
private final Map<String, LevelDefinition.Door> doorsById = new HashMap<>();
private PuzzleSystem puzzleSystem = new PuzzleSystem();
private final AccessibilityStateCache accessibilityStateCache = private final AccessibilityStateCache accessibilityStateCache =
new AccessibilityStateCache(); new AccessibilityStateCache();
private final VolunteerCanvasRenderer volunteerRenderer = new VolunteerCanvasRenderer(); private final VolunteerCanvasRenderer volunteerRenderer = new VolunteerCanvasRenderer();
@@ -266,7 +269,6 @@ public final class GameView extends View implements Runnable {
private AccessibilitySettings accessibilitySettings; private AccessibilitySettings accessibilitySettings;
private AudioSettings audioSettings; private AudioSettings audioSettings;
private float lastFootstepAt = -10f; private float lastFootstepAt = -10f;
private boolean previousAllDoorsOpen;
private boolean deterministicAutomationClock; private boolean deterministicAutomationClock;
private double deterministicAccumulatorSeconds; private double deterministicAccumulatorSeconds;
private float lastDrawDurationMs; private float lastDrawDurationMs;
@@ -385,7 +387,6 @@ public final class GameView extends View implements Runnable {
cameraShakeY = 0f; cameraShakeY = 0f;
cameraController.reset(); cameraController.reset();
lastFootstepAt = -10f; lastFootstepAt = -10f;
previousAllDoorsOpen = false;
performanceMonitor.reset(); performanceMonitor.reset();
performanceFrameCounter = 0; performanceFrameCounter = 0;
userPaused = false; userPaused = false;
@@ -393,9 +394,8 @@ public final class GameView extends View implements Runnable {
deriveControls(); deriveControls();
corpseManager.reset(); corpseManager.reset();
platformStates.clear(); platformStates.clear();
toggleSignals.clear();
timedSignals.clear();
previousSwitchContact.clear(); previousSwitchContact.clear();
configurePuzzleSystem();
completionResult = null; completionResult = null;
configureAccessibilitySignals(); configureAccessibilitySignals();
invalidateHudTextCache(); invalidateHudTextCache();
@@ -451,17 +451,6 @@ public final class GameView extends View implements Runnable {
lastFootstepAt = levelElapsed; lastFootstepAt = levelElapsed;
audioEngine.play(AudioCue.FOOTSTEP); 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(); refreshAccessibilityDescriptionIfStateChanged();
CameraController.Frame camera = cameraController.update(dt, CameraController.Frame camera = cameraController.update(dt,
@@ -473,30 +462,85 @@ public final class GameView extends View implements Runnable {
cameraShakeY = camera.shakeY(); cameraShakeY = camera.shakeY();
} }
private void updateSignals(float dt) { private void configurePuzzleSystem() {
for (Map.Entry<String, Float> item : new ArrayList<>(timedSignals.entrySet())) { doorControllers.clear();
timedSignals.put(item.getKey(), Math.max(0f, item.getValue() - dt)); doorsById.clear();
ArrayList<DoorController> 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<PuzzleSystem.Input> inputs = new ArrayList<>(
level.switches.size() + level.hazards.size());
for (LevelDefinition.Switch item : level.switches) { for (LevelDefinition.Switch item : level.switches) {
boolean contact = switchContact(item); boolean contact = switchContact(item);
String contactKey = item.signalId + "@" + item.box.x; String contactKey = item.signalId + "@" + item.box.x;
boolean previous = Boolean.TRUE.equals(previousSwitchContact.get(contactKey)); boolean previous = Boolean.TRUE.equals(previousSwitchContact.get(contactKey));
if (item.type == LevelDefinition.SwitchType.TOGGLE && contact && !previous) { switch (item.type) {
toggleSignals.put(item.signalId, !Boolean.TRUE.equals(toggleSignals.get(item.signalId))); 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); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} }
if (item.type == LevelDefinition.SwitchType.TIMED && contact && !previous) { if (feedback && contact && !previous) audioEngine.play(AudioCue.SWITCH);
timedSignals.put(item.signalId, item.durationSeconds); if (feedback && contact && !previous && player.alive
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
}
if (contact && !previous) audioEngine.play(AudioCue.SWITCH);
if (contact && !previous && player.alive
&& intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) { && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) {
characterStateMachine.beginInteraction(); characterStateMachine.beginInteraction();
} }
previousSwitchContact.put(contactKey, contact); 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() { private boolean allDoorsOpenNow() {
@@ -515,28 +559,12 @@ public final class GameView extends View implements Runnable {
} }
private boolean signalActive(String id) { private boolean signalActive(String id) {
if (id == null || id.isEmpty()) return true; return puzzleSystem.signalActive(id);
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;
} }
private boolean doorOpen(LevelDefinition.Door door) { private boolean doorOpen(LevelDefinition.Door door) {
for (String signal : door.requiredSignals) { DoorController controller = doorControllers.get(door);
if (!signalActive(signal)) return false; return controller != null && controller.open();
}
return true;
} }
private void updatePlatforms(float dt) { private void updatePlatforms(float dt) {
@@ -1003,8 +1031,9 @@ public final class GameView extends View implements Runnable {
stroke.setStrokeWidth(7f); stroke.setStrokeWidth(7f);
canvas.drawLine(box.centerX(), box.centerY(), box.centerX() + (active ? 20 : -20), box.top - 28, stroke); canvas.drawLine(box.centerX(), box.centerY(), box.centerX() + (active ? 20 : -20), box.top - 28, stroke);
if (item.type == LevelDefinition.SwitchType.TIMED && active) { if (item.type == LevelDefinition.SwitchType.TIMED && active) {
Float remaining = timedSignals.get(item.signalId); float remaining = puzzleSystem.remainingSeconds(item.signalId);
drawWorldLabel(canvas, String.format(Locale.US, "%.1f", remaining == null ? 0f : remaining), box.centerX(), box.top - 52); drawWorldLabel(canvas, String.format(Locale.US, "%.1f", remaining),
box.centerX(), box.top - 52);
} }
drawWorldLabel(canvas, active ? "ON" : "OFF", box.centerX(), box.bottom + 32); drawWorldLabel(canvas, active ? "ON" : "OFF", box.centerX(), box.bottom + 32);
} }
@@ -2282,10 +2311,11 @@ public final class GameView extends View implements Runnable {
ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size()); ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size());
for (PlatformState state : platformStates) motionTimes.add(state.motionTime); for (PlatformState state : platformStates) motionTimes.add(state.motionTime);
builder.platformMotionTimes(motionTimes); builder.platformMotionTimes(motionTimes);
for (Map.Entry<String, Boolean> entry : toggleSignals.entrySet()) { PuzzleSystem.State puzzleState = puzzleSystem.snapshot();
for (Map.Entry<String, Boolean> entry : puzzleState.toggleSignals().entrySet()) {
builder.toggleSignal(entry.getKey(), Boolean.TRUE.equals(entry.getValue())); builder.toggleSignal(entry.getKey(), Boolean.TRUE.equals(entry.getValue()));
} }
for (Map.Entry<String, Float> entry : timedSignals.entrySet()) { for (Map.Entry<String, Float> entry : puzzleState.timedSignals().entrySet()) {
builder.timedSignal(entry.getKey(), Math.max(0f, entry.getValue())); builder.timedSignal(entry.getKey(), Math.max(0f, entry.getValue()));
} }
for (Map.Entry<String, Boolean> entry : previousSwitchContact.entrySet()) { for (Map.Entry<String, Boolean> entry : previousSwitchContact.entrySet()) {
@@ -2316,12 +2346,10 @@ public final class GameView extends View implements Runnable {
levelElapsed = snapshot.elapsedSeconds(); levelElapsed = snapshot.elapsedSeconds();
deaths = snapshot.deaths(); deaths = snapshot.deaths();
respawnTimer = snapshot.respawnSeconds(); respawnTimer = snapshot.respawnSeconds();
toggleSignals.clear();
toggleSignals.putAll(snapshot.toggleSignals());
timedSignals.clear();
timedSignals.putAll(snapshot.timedSignals());
previousSwitchContact.clear(); previousSwitchContact.clear();
previousSwitchContact.putAll(snapshot.previousSwitchContacts()); previousSwitchContact.putAll(snapshot.previousSwitchContacts());
puzzleSystem.restore(snapshot.toggleSignals(), snapshot.timedSignals(),
snapshot.previousSwitchContacts());
for (int i = 0; i < platformStates.size(); i++) { for (int i = 0; i < platformStates.size(); i++) {
PlatformState state = platformStates.get(i); PlatformState state = platformStates.get(i);
state.motionTime = snapshot.platformMotionTimes().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); corpseManager.transform(corpse, CorpseManager.Transformation.PINNED);
} }
} }
updateSignals(0f, false);
leftPressed = false; leftPressed = false;
rightPressed = false; rightPressed = false;
@@ -2370,7 +2399,6 @@ public final class GameView extends View implements Runnable {
userPaused = true; userPaused = true;
paused = false; paused = false;
lastFootstepAt = levelElapsed; lastFootstepAt = levelElapsed;
previousAllDoorsOpen = allDoorsOpenNow();
cameraController.reset(); cameraController.reset();
cameraX = cameraY = cameraShakeX = cameraShakeY = 0f; cameraX = cameraY = cameraShakeX = cameraShakeY = 0f;
lastFrameNanos = System.nanoTime(); lastFrameNanos = System.nanoTime();
@@ -2448,4 +2476,8 @@ public final class GameView extends View implements Runnable {
public CorpseManager.Diagnostics corpseDiagnosticsForAutomation() { public CorpseManager.Diagnostics corpseDiagnosticsForAutomation() {
return corpseManager.diagnostics(); return corpseManager.diagnostics();
} }
public PuzzleSystem.Diagnostics puzzleDiagnosticsForAutomation() {
return puzzleSystem.diagnostics();
}
} }
@@ -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<String, Boolean> toggleSignals;
private final Map<String, Float> timedSignals;
private State(Map<String, Boolean> toggleSignals,
Map<String, Float> timedSignals) {
this.toggleSignals = Collections.unmodifiableMap(
new HashMap<>(toggleSignals));
this.timedSignals = Collections.unmodifiableMap(
new HashMap<>(timedSignals));
}
public Map<String, Boolean> toggleSignals() { return toggleSignals; }
public Map<String, Float> 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<String, Boolean> toggleChannels = new HashMap<>();
private final Map<String, Float> timedChannels = new HashMap<>();
private final Map<String, Boolean> previousSourceActivity = new HashMap<>();
private final Map<String, SourceKind> sourceKinds = new HashMap<>();
private final Map<String, String> sourceChannels = new HashMap<>();
private final Set<String> knownChannels = new LinkedHashSet<>();
private final List<DoorController> doors;
private List<Event> 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<DoorController> doors) {
if (doors == null) throw new IllegalArgumentException("doors must not be null");
ArrayList<DoorController> validated = new ArrayList<>(doors.size());
LinkedHashSet<String> 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<Input> 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<String> affected = new LinkedHashSet<>(knownChannels);
for (Input input : inputs) {
if (input == null) throw new IllegalArgumentException("input must not be null");
affected.add(input.channelId);
}
Map<String, Boolean> before = new HashMap<>();
for (String channelId : affected) before.put(channelId, signalActive(channelId));
for (Map.Entry<String, Float> 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<Event> 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<String, String> 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<Event> 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<String, Boolean> sourceActivity) {
if (state == null) throw new IllegalArgumentException("state must not be null");
restore(state.toggleSignals(), state.timedSignals(), sourceActivity);
}
public void restore(Map<String, Boolean> toggles, Map<String, Float> timers) {
restore(toggles, timers, Collections.emptyMap());
}
public void restore(Map<String, Boolean> toggles, Map<String, Float> timers,
Map<String, Boolean> 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;
}
}
@@ -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;
}
}
+6 -3
View File
@@ -179,12 +179,15 @@ baseline-задачи также имели явного владельца по
- принят `goon-game-milestone-production`. - принят `goon-game-milestone-production`.
Статус на 19 августа 2026: Stage 6 начат в изолированном production stream. Статус на 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. явные Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions.
`goon-game-corpse-manager` перенёс регистрацию, transforms, FIFO budget, reset, `goon-game-corpse-manager` перенёс регистрацию, transforms, FIFO budget, reset,
save/restore и diagnostics тел в отдельного владельца без изменения физики и save/restore и diagnostics тел в отдельного владельца без изменения физики и
геометрии. Последний gate: 84 JVM-теста и Android 16 matrix `37/37`. геометрии. `goon-game-puzzle-events` выделил pressure/toggle/timed/relay channels,
Следующий foundation slice — event-driven `PuzzleSystem`. 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 дней ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
+63
View File
@@ -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.