Завершить инженерный кандидат Stage 5

This commit is contained in:
2026-08-19 02:04:45 +03:00
parent b840684ef3
commit cde886ca96
45 changed files with 3236 additions and 98 deletions
+15 -15
View File
@@ -1,28 +1,28 @@
{"_type":"issue","id":"goon-game-level10-sliding-corpse","title":"Остановить скольжение тела на плите уровня 10","description":"Брошенное пилой тело уровня 10 может продолжать скользить по полу и съезжать с PRESSURE lift, из-за чего движущаяся платформа теряет сигнал и intended route становится нестабильным.","acceptance_criteria":"После доставки тело стабильно остаётся на PRESSURE lift без ручного тайминга; сигнал lift не пропадает при длительном ожидании; уровень 10 проходит normal и delayed-input маршруты по 3 раза без дополнительных смертей.","notes":"2026-08-18: focused RED showed firstCorpseX drift 791.4791 -\u003e 866.5033 in 750 ms. Grounded bodies now settle fully inside PRESSURE, stop, and pin. GREEN: position stable, lift powered after 6 s, exactly 1 death/body; level 10 delayed run and 3 consecutive mastery runs passed; final unfiltered matrix 16/16.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T08:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:53Z","started_at":"2026-08-18T08:40:02Z","closed_at":"2026-08-18T10:41:47Z","close_reason":"Acceptance met: delivered body remains on lift plate, signal stays active, normal 3x and delayed-input level 10 routes pass.","metadata":{"stages":2,"primary_stage":2},"labels":["bug","current","level10","physics","primary-stage-2","stage-2"],"dependencies":[{"issue_id":"goon-game-level10-sliding-corpse","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T11:40:01Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-level6-freezer-overlap","title":"Исправить повторную смерть на frozen corpse уровня 6","description":"Frozen corpse уровня 6 фиксируется на y=776..880, а FREEZER остаётся y=750..880; игрок на теле занимает y=676..776 и пересекает hazard на 26 px, поэтому intended stepping-stone route вызывает немедленную вторую смерть.","acceptance_criteria":"Игрок может стоять и прыгнуть с предусмотренного frozen corpse без повторной FREEZE-смерти; вход в FREEZER с пола всё ещё убивает; intended route уровня 6 проходит три раза подряд production-input ботом.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:46:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:53Z","started_at":"2026-08-17T22:46:52Z","closed_at":"2026-08-18T01:30:37Z","close_reason":"FREEZER больше не убивает игрока на frozen stepping stone; floor lethality сохранена; уровень 6 проходит 3/3.","metadata":{"stages":2,"primary_stage":2},"labels":["bug","current","freeze","level6","primary-stage-2","stage-2"],"dependencies":[{"issue_id":"goon-game-level6-freezer-overlap","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:46:51Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-level8-softlock","title":"Устранить softlock таймера уровня 8","description":"Если clock истекает после пересечения PIT, дверь закрывается, платформа останавливается, а переключатель остаётся слева: игрок может оказаться заперт справа до restart.","acceptance_criteria":"Бот воспроизводит expiry после PIT; игрок всегда имеет путь к restart-free recovery или таймер/геометрия исключают ловушку; сценарий проходит 3 раза подряд.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:40Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:54Z","started_at":"2026-08-17T22:33:08Z","closed_at":"2026-08-18T01:30:42Z","close_reason":"Expiry справа от PIT воспроизводился; recovery relay и баланс таймера проверены intended 3/3 и recovery 3/3.","metadata":{"stages":2,"primary_stage":2},"labels":["bug","current","level8","primary-stage-2","softlock","stage-2"],"dependencies":[{"issue_id":"goon-game-level8-softlock","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:09:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-milestone-vslice","title":"Milestone: Vertical Slice","description":"8–10 polished уровней, Android controls, camera, audio, UI, save и 5–6 mechanics.","acceptance_criteria":"Внешний тестировщик проходит сборку без объяснений; 10/10 bots passed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:09Z","started_at":"2026-08-17T21:41:29Z","metadata":{"stages":5,"primary_stage":5},"labels":["current","gate","milestone","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-android-lifecycle","type":"blocks","created_at":"2026-08-18T00:44:22Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-art-vslice","type":"blocks","created_at":"2026-08-18T00:44:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-audio-vslice","type":"blocks","created_at":"2026-08-18T00:44:19Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-controls-settings","type":"blocks","created_at":"2026-08-18T00:44:17Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:55Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-human-playtest","type":"blocks","created_at":"2026-08-18T00:44:16Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-menus-localization","type":"blocks","created_at":"2026-08-18T00:44:18Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:44:23Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:44:15Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-save-versioning","type":"blocks","created_at":"2026-08-18T00:44:21Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":9,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-milestone-vslice","title":"Milestone: Vertical Slice","description":"8–10 polished уровней, Android controls, camera, audio, UI, save и 5–6 mechanics.","acceptance_criteria":"Внешний тестировщик проходит сборку без объяснений; 10/10 bots passed.","notes":"2026-08-19 engineering RC complete: art/audio/camera/save/lifecycle/determinism/performance tasks closed; clean 72 JVM tests and exact Android 16 35/35 in 493.521s; APK format/signature/install/cold-start verified. Formal milestone remains in_progress solely for goon-game-human-playtest and the linked external comprehension portion of goon-game-level-balance.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:31Z","started_at":"2026-08-17T21:41:29Z","metadata":{"stages":5,"primary_stage":5},"labels":["current","gate","milestone","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-android-lifecycle","type":"blocks","created_at":"2026-08-18T00:44:22Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-art-vslice","type":"blocks","created_at":"2026-08-18T00:44:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-audio-vslice","type":"blocks","created_at":"2026-08-18T00:44:19Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-controls-settings","type":"blocks","created_at":"2026-08-18T00:44:17Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:55Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-human-playtest","type":"blocks","created_at":"2026-08-18T00:44:16Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-menus-localization","type":"blocks","created_at":"2026-08-18T00:44:18Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:44:23Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:44:15Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-save-versioning","type":"blocks","created_at":"2026-08-18T00:44:21Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":9,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-milestone-prototype","title":"Milestone: Prototype","description":"Character, death, corpse, respawn, spikes, plate, door, goal и restart без финального art.","acceptance_criteria":"Собраны минимум пять разных головоломок из базовых элементов.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:02Z","closed_at":"2026-08-17T21:41:27Z","close_reason":"Exit criteria прототипа подтверждены 10 greybox-уровнями.","metadata":{"stages":0,"primary_stage":0},"labels":["closed","milestone","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-milestone-prototype","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-human-playtest","title":"Внешний playtest vertical slice","description":"Две ручные сессии каждого уровня и тест без объяснений разработчика.","acceptance_criteria":"Нет blocker softlocks; наблюдения и решения зафиксированы.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:09Z","metadata":{"stages":5,"primary_stage":5},"labels":["primary-stage-5","qa","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:51Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-level-balance","type":"blocks","created_at":"2026-08-18T00:41:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-regression-10","title":"Единый playability regression 10/10","description":"Запускать всех ботов одной Gradle-командой и публиковать краткий отчёт по уровням.","acceptance_criteria":"Три полных последовательных прогона дают 10/10 passed менее чем за 5 минут каждый.","notes":"2026-08-18: one final integrated Android 16 run is green: 14/14 methods, 35 gameplay executions plus lifecycle, Gradle wall time 5:59. Correctness is proven, but acceptance remains open because the \u003c5:00 target is missed by 59s and three full matrix repetitions are not yet recorded.\n2026-08-18 final Stage 2 candidate: one exact unfiltered Android 16 run passed 16/16 methods, 36 gameplay executions plus lifecycle/restart in 479.619 s. Correctness gate is green; historical \u003c5:00 and three full exact-candidate repetitions remain open as runner-performance evidence.\n2026-08-18 exact versionCode 5 clean-build matrix: OK 16/16, 36 gameplay executions plus lifecycle/restart, 572.496 s, no retries. APK installed over previous build before the run.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":480,"created_at":"2026-08-17T21:40:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:16Z","metadata":{"stages":"1,5","primary_stage":5},"labels":["current","gate","primary-stage-5","qa","stage-1","stage-5"],"dependencies":[{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-1-4","type":"blocks","created_at":"2026-08-18T00:41:33Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-6-10","type":"blocks","created_at":"2026-08-18T00:41:34Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-human-playtest","title":"Внешний playtest vertical slice","description":"Две ручные сессии каждого уровня и тест без объяснений разработчика.","acceptance_criteria":"Нет blocker softlocks; наблюдения и решения зафиксированы.","notes":"2026-08-19 immutable candidate prepared: 0.8.0-stage5-vslice-debug, versionCode 9, APK SHA-256 8bb970c7385a48d8002907382067a5f8f87554a33ad7aaace9aa593194c8fed5. Protocol and required per-session record are in docs/STAGE5_HUMAN_PLAYTEST.md. Zero of 20 external sessions may be inferred from bot/developer testing.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:28Z","metadata":{"stages":5,"primary_stage":5},"labels":["primary-stage-5","qa","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:51Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-level-balance","type":"blocks","created_at":"2026-08-18T00:41:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-regression-10","title":"Единый playability regression 10/10","description":"Запускать всех ботов одной Gradle-командой и публиковать краткий отчёт по уровням.","acceptance_criteria":"Три полных последовательных прогона дают 10/10 passed менее чем за 5 минут каждый.","notes":"2026-08-18: one final integrated Android 16 run is green: 14/14 methods, 35 gameplay executions plus lifecycle, Gradle wall time 5:59. Correctness is proven, but acceptance remains open because the \u003c5:00 target is missed by 59s and three full matrix repetitions are not yet recorded.\n2026-08-18 final Stage 2 candidate: one exact unfiltered Android 16 run passed 16/16 methods, 36 gameplay executions plus lifecycle/restart in 479.619 s. Correctness gate is green; historical \u003c5:00 and three full exact-candidate repetitions remain open as runner-performance evidence.\n2026-08-18 exact versionCode 5 clean-build matrix: OK 16/16, 36 gameplay executions plus lifecycle/restart, 572.496 s, no retries. APK installed over previous build before the run.\nStage 5 RC: deterministic runner records all 10 routes and replays three full sequential 10/10 passes with matching signatures and under-five-minute guards. Exact final unfiltered Android run also GREEN 35/35 in 493.521s, no retries.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":480,"created_at":"2026-08-17T21:40:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:22Z","closed_at":"2026-08-18T23:02:22Z","close_reason":"Three full 10/10 replay passes satisfy the under-five-minute and reproducibility acceptance.","metadata":{"stages":"1,5","primary_stage":5},"labels":["current","gate","primary-stage-5","qa","stage-1","stage-5"],"dependencies":[{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-1-4","type":"blocks","created_at":"2026-08-18T00:41:33Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-6-10","type":"blocks","created_at":"2026-08-18T00:41:34Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-bots-6-10","title":"Добавить ботов уровней 6–10","description":"Доказать freeze, electricity, timed platform, multi-signal и mastery level.","acceptance_criteria":"Уровни 6–10 проходят по три раза подряд без retries.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:48Z","started_at":"2026-08-17T22:33:08Z","closed_at":"2026-08-18T01:30:46Z","close_reason":"Уровни 6–10 проходят intended routes по 3 раза; уровни 8 и 10 дополнительно проходят 96ms stress checks.","metadata":{"stages":1,"primary_stage":1},"labels":["bot","current","primary-stage-1","qa","stage-1"],"dependencies":[{"issue_id":"goon-game-bots-6-10","depends_on_id":"goon-game-bot-framework","type":"blocks","created_at":"2026-08-18T00:41:32Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-bots-6-10","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:49Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-bots-6-10","depends_on_id":"goon-game-level6-freezer-overlap","type":"blocks","created_at":"2026-08-18T01:47:00Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-bots-1-4","title":"Добавить ботов уровней 1–4","description":"Доказать movement/jump, spikes/corpses, pressure door и moving lift реальными inputs.","acceptance_criteria":"Уровни 1–4 проходят по три раза подряд без retries.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:48Z","started_at":"2026-08-17T22:33:08Z","closed_at":"2026-08-18T01:30:34Z","close_reason":"Уровни 1–4 проходят production-input ботами по 3 раза подряд; финальный integrated Android 16 matrix 14/14 green.","metadata":{"stages":1,"primary_stage":1},"labels":["bot","current","primary-stage-1","qa","stage-1"],"dependencies":[{"issue_id":"goon-game-bots-1-4","depends_on_id":"goon-game-bot-framework","type":"blocks","created_at":"2026-08-18T00:41:30Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-bots-1-4","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-bot-framework","title":"Обобщить acceptance bot framework","description":"Выделить сценарные шаги, snapshots, диагностику таймаутов и общий runner для уровней 1–10.","acceptance_criteria":"Новый уровень подключается декларативным сценарием; ошибка показывает последний шаг и state.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:48Z","started_at":"2026-08-17T21:52:45Z","closed_at":"2026-08-17T22:09:01Z","close_reason":"Декларативный Scenario runner, именованные шаги, per-step timeout и observable diagnostics реализованы; 11 JVM-тестов, lint, instrumentation compile и connected level 5 GREEN на Android 16.","metadata":{"stages":1,"primary_stage":1},"labels":["bot","current","primary-stage-1","qa","stage-1"],"dependencies":[{"issue_id":"goon-game-bot-framework","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-level5-bot","title":"Acceptance-бот уровня 5","description":"Бот использует production GameView inputs, одну жертву, тело, плиту, дверь и goal.","acceptance_criteria":"Инструментальный тест проходит на Android 16 примерно за 8,2 секунды.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:42Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:49Z","closed_at":"2026-08-17T21:41:25Z","close_reason":"Android acceptance-тест уровня 5 проходит.","metadata":{"stages":1,"primary_stage":1},"labels":["baseline","bot","closed","primary-stage-1","stage-1"],"dependencies":[{"issue_id":"goon-game-level5-bot","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:46Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-unit-tests","title":"Базовые unit tests и Android Lint","description":"В текущем проекте проходят 8 unit-тестов и lintDebug.","acceptance_criteria":"Чистая Gradle-сборка зелёная.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:02Z","closed_at":"2026-08-17T21:41:24Z","close_reason":"8 unit-тестов и lintDebug прошли.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0","tests"],"dependencies":[{"issue_id":"goon-game-unit-tests","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-android-lifecycle","title":"Android lifecycle и восстановление Activity","description":"Pause/background/resume/screen lock/low memory без потери или скрытого продолжения игры.","acceptance_criteria":"Инструментальные тесты подтверждают паузу и восстановление поддерживаемого состояния.","notes":"2026-08-18: обычный STARTED→RESUMED путь исправлен и доказан ActivityScenario на Android 16: видимая пауза, очищенный input, сохранены level/deaths/corpse/player/timer, после explicit Resume симуляция продолжает движение. Connected GREEN 1/1. Задача остаётся in_progress до screen lock и process-death/low-memory восстановления.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:26Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:10Z","started_at":"2026-08-17T21:52:46Z","metadata":{"stages":5,"primary_stage":5},"labels":["android","current","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-android-lifecycle","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-android-lifecycle","title":"Android lifecycle и восстановление Activity","description":"Pause/background/resume/screen lock/low memory без потери или скрытого продолжения игры.","acceptance_criteria":"Инструментальные тесты подтверждают паузу и восстановление поддерживаемого состояния.","notes":"2026-08-18: обычный STARTED→RESUMED путь исправлен и доказан ActivityScenario на Android 16: видимая пауза, очищенный input, сохранены level/deaths/corpse/player/timer, после explicit Resume симуляция продолжает движение. Connected GREEN 1/1. Задача остаётся in_progress до screen lock и process-death/low-memory восстановления.\nStage 5 RC: background/resume clears held input and shows visible pause; Activity recreation restores level/player/bodies/timers/platform signals; focused and full Android gates GREEN. Broader API/device/low-memory matrix remains separately owned by Stage 7.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:26Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:01Z","started_at":"2026-08-17T21:52:46Z","closed_at":"2026-08-18T23:02:01Z","close_reason":"Supported lifecycle and Activity/process-like recreation state restore are proven for the Vertical Slice.","metadata":{"stages":5,"primary_stage":5},"labels":["android","current","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-android-lifecycle","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-android-apk","title":"Собираемый и устанавливаемый Android APK","description":"Debug APK versionCode 3 проверен на Android 16, ZIP и подписи валидны.","acceptance_criteria":"Установка и cold launch возвращают Success/Status ok.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:24Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:02Z","closed_at":"2026-08-17T21:41:23Z","close_reason":"APK versionCode 3 собран, подписан, установлен и запущен.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-android-apk","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-level-balance","title":"Исправить стабильность и баланс уровней 1–10","description":"Устранить softlocks, нестабильные тела и чрезмерно точные прыжки, найденные ботами и ручным тестированием.","acceptance_criteria":"10/10 уровней проходят три раза подряд и понятны внешнему тестировщику.","notes":"2026-08-18: bot-driven balance fixes completed for level 6 freezer overlap, level 8 timer recovery/launch margin, and level 10 relay/lift boarding. Intended routes 1–10 pass 3x in one integrated matrix. Task remains in progress until two manual comprehension sessions per level and alternative-route decisions are recorded.\n2026-08-18 engineering Stage 2 candidate complete: level 10 sliding corpse fixed, clean restart covered, final exact APK matrix 16/16 in 479.619 s. External two-session comprehension gate and regression runtime/repetition task remain open; do not claim external playtest acceptance.\n2026-08-18 exact versionCode 5 full matrix also GREEN 16/16 in 572.496 s. Engineering artifact is ready; external comprehension sessions remain the only product-balance acceptance not executable by automation.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:20Z","started_at":"2026-08-18T01:31:03Z","metadata":{"stages":"2,5","primary_stage":5},"labels":["balance","current","levels","primary-stage-5","stage-2","stage-5"],"dependencies":[{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level-route-risks","type":"blocks","created_at":"2026-08-18T01:10:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level10-sliding-corpse","type":"blocks","created_at":"2026-08-18T11:40:08Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level8-softlock","type":"blocks","created_at":"2026-08-18T01:10:14Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-solution-graphs","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-level-balance","title":"Исправить стабильность и баланс уровней 1–10","description":"Устранить softlocks, нестабильные тела и чрезмерно точные прыжки, найденные ботами и ручным тестированием.","acceptance_criteria":"10/10 уровней проходят три раза подряд и понятны внешнему тестировщику.","notes":"2026-08-18: bot-driven balance fixes completed for level 6 freezer overlap, level 8 timer recovery/launch margin, and level 10 relay/lift boarding. Intended routes 1–10 pass 3x in one integrated matrix. Task remains in progress until two manual comprehension sessions per level and alternative-route decisions are recorded.\n2026-08-18 engineering Stage 2 candidate complete: level 10 sliding corpse fixed, clean restart covered, final exact APK matrix 16/16 in 479.619 s. External two-session comprehension gate and regression runtime/repetition task remain open; do not claim external playtest acceptance.\n2026-08-18 exact versionCode 5 full matrix also GREEN 16/16 in 572.496 s. Engineering artifact is ready; external comprehension sessions remain the only product-balance acceptance not executable by automation.\n2026-08-19 Stage 5 RC: exact unfiltered Android 16 matrix GREEN 35/35 in 493.521s; deterministic 10-level tapes replay 3x with matching signatures; level 8 recovery and level 10 body stability/delayed traversal GREEN. Engineering balance is complete. Task remains in_progress only because external comprehension requires 20 independent sessions; see docs/STAGE5_HUMAN_PLAYTEST.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:24Z","started_at":"2026-08-18T01:31:03Z","metadata":{"stages":"2,5","primary_stage":5},"labels":["balance","current","levels","primary-stage-5","stage-2","stage-5"],"dependencies":[{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level-route-risks","type":"blocks","created_at":"2026-08-18T01:10:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level10-sliding-corpse","type":"blocks","created_at":"2026-08-18T11:40:08Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level8-softlock","type":"blocks","created_at":"2026-08-18T01:10:14Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-solution-graphs","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-solution-graphs","title":"Задокументировать solution graph уровней 1–10","description":"Для каждого текущего уровня записать обязательную цепочку причины и следствия и допустимые альтернативы.","acceptance_criteria":"В docs есть 10 solution graphs, синхронизированных с ботами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:54Z","started_at":"2026-08-17T21:52:46Z","closed_at":"2026-08-17T22:09:09Z","close_reason":"Документированы 10 source-backed solution graphs с механиками, смертями, checkpoints и рисками; только уровень 5 честно отмечен доказанным connected-тестом.","metadata":{"stages":2,"primary_stage":2},"labels":["current","design","levels","primary-stage-2","stage-2"],"dependencies":[{"issue_id":"goon-game-solution-graphs","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:19Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-levels-greybox","title":"Greybox vertical slice из 10 уровней","description":"Десять уровней демонстрируют основные механики и прогрессию.","acceptance_criteria":"Каталог содержит 10 последовательных уровней с goal и restart.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:52Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:03Z","closed_at":"2026-08-17T21:41:19Z","close_reason":"Созданы 10 последовательных greybox-уровней.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0","vertical-slice"],"dependencies":[{"issue_id":"goon-game-levels-greybox","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:17Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-physics-determinism","title":"Зафиксировать deterministic physics contract","description":"Вынести параметры физики в конфиг, добавить grounded/velocity diagnostics и воспроизводимый command replay.","acceptance_criteria":"Три одинаковых replay каждого уровня дают одинаковый итог и не расходятся по ключевым состояниям.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:33Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:10Z","metadata":{"stages":5,"primary_stage":5},"labels":["current","physics","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-physics-determinism","depends_on_id":"goon-game-e03","type":"parent-child","created_at":"2026-08-18T00:43:08Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-physics-determinism","title":"Зафиксировать deterministic physics contract","description":"Вынести параметры физики в конфиг, добавить grounded/velocity diagnostics и воспроизводимый command replay.","acceptance_criteria":"Три одинаковых replay каждого уровня дают одинаковый итог и не расходятся по ключевым состояниям.","notes":"Stage 5 RC: PhysicsConfig v1 and fixed 1/60 step; exact command tapes for levels 1-10 replay three sequential passes with identical state signatures and death/body counts, each pass guarded under five minutes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:33Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:08Z","closed_at":"2026-08-18T23:02:08Z","close_reason":"Three identical replays for every level produce matching states and outcomes.","metadata":{"stages":5,"primary_stage":5},"labels":["current","physics","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-physics-determinism","depends_on_id":"goon-game-e03","type":"parent-child","created_at":"2026-08-18T00:43:08Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-physics-basic","title":"Базовая предсказуемая Canvas-физика","description":"Коллизии персонажа, тел, платформ и дверей работают в текущем vertical slice.","acceptance_criteria":"Unit-тесты и бот уровня 5 проходят.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:31Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:03Z","closed_at":"2026-08-17T21:41:15Z","close_reason":"Базовая физика присутствует; production determinism остаётся отдельной задачей.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-physics-basic","depends_on_id":"goon-game-e03","type":"parent-child","created_at":"2026-08-18T00:43:07Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-death-core","title":"Death, corpse и respawn без сброса уровня","description":"Работающая основа смерти, сохранения тела и появления следующего персонажа.","acceptance_criteria":"Проверено на уровне 5 и smoke-тестах APK.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:22Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:03Z","closed_at":"2026-08-17T21:41:14Z","close_reason":"Реализовано и проверено ботом уровня 5.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-death-core","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:03Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e16","title":"EPIC-16 Milestones \u0026 Release","description":"Prototype → Vertical Slice → Production → Alpha → Beta → Release Candidate. Разделы 53–59.","acceptance_criteria":"Есть подписанный store build без blocker и critical progression bugs.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:16Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:04:08Z","started_at":"2026-08-17T21:41:29Z","metadata":{"stages":"0,5,6,8,9,10","primary_stage":10},"labels":["epic","milestone","primary-stage-10","release","stage-0","stage-10","stage-5","stage-6","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e16","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:43:00Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e15","title":"EPIC-15 QA","description":"Unit, integration, playability bots, softlock и mobile QA. Разделы 48–51, 63–64.","acceptance_criteria":"Все уровни автоматически и вручную проверены по обязательной матрице.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:14Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:04:02Z","started_at":"2026-08-17T21:41:28Z","metadata":{"stages":"0,1,2,3,4,5,6,7,8,9,10","primary_stage":10},"labels":["current","epic","primary-stage-10","qa","stage-0","stage-1","stage-10","stage-2","stage-3","stage-4","stage-5","stage-6","stage-7","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e15","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:58Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e12","title":"EPIC-12 Android","description":"Lifecycle, устройства, aspect ratios, graphics profiles и сборки. Разделы 41, 44–46, 49.","acceptance_criteria":"Release build стабильно работает на целевой Android-матрице.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:08Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:47Z","metadata":{"stages":"0,3,5,7","primary_stage":7},"labels":["android","epic","primary-stage-7","stage-0","stage-3","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e12","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:55Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e07","title":"EPIC-07 Levels","description":"Solution graphs, greybox, прогрессия механик и производство 55–70 уровней. Разделы 19–25, 52–53, 66.","acceptance_criteria":"Каждый уровень имеет доказанное решение, DoD и место в content matrix.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:57Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:25Z","metadata":{"stages":"0,2,4,6","primary_stage":6},"labels":["content","epic","levels","primary-stage-6","stage-0","stage-2","stage-4","stage-6"],"dependencies":[{"issue_id":"goon-game-e07","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:49Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e03","title":"EPIC-03 Physics","description":"Предсказуемая гибридная физика и бюджеты активных объектов. Разделы 15, 41–43, 65.","acceptance_criteria":"Одинаковая последовательность ввода воспроизводимо приводит к одинаковому состоянию головоломки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:07Z","metadata":{"stages":"0,5,7","primary_stage":5},"labels":["epic","physics","primary-stage-5","stage-0","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e03","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e03","title":"EPIC-03 Physics","description":"Предсказуемая гибридная физика и бюджеты активных объектов. Разделы 15, 41–43, 65.","acceptance_criteria":"Одинаковая последовательность ввода воспроизводимо приводит к одинаковому состоянию головоломки.","notes":"Stage 5 closed the deterministic physics contract: PhysicsConfig v1 and 10 exact command traces replayed 3x with identical signatures; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:55Z","closed_at":"2026-08-18T23:02:55Z","close_reason":"Physics foundation and deterministic replay acceptance are complete.","metadata":{"stages":"0,5,7","primary_stage":5},"labels":["epic","physics","primary-stage-5","stage-0","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e03","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e02","title":"EPIC-02 Death \u0026 Corpse","description":"Смерть как игровая механика, состояния тела, CorpseManager и respawn. Разделы 4–8.","acceptance_criteria":"Все типы смерти создают предсказуемое и пригодное для головоломки состояние тела.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:03Z","metadata":{"stages":"0,6,7","primary_stage":6},"labels":["corpse","death","epic","primary-stage-6","stage-0","stage-6","stage-7"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-plan","title":"Project Sacrifice: выполнить утверждённый production plan","description":"Главный bead для плана из docs/DEVELOPMENT_PLAN.md: vertical slice, production, alpha, beta и release candidate.","acceptance_criteria":"Release Candidate удовлетворяет критериям раздела 59; все дочерние эпики завершены либо явно исключены решением владельца продукта.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:23Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:51Z","started_at":"2026-08-17T21:41:28Z","metadata":{"stages":"0,1,2,3,4,5,6,7,8,9,10","primary_stage":10},"labels":["gdd","master","primary-stage-10","production","stage-0","stage-1","stage-10","stage-2","stage-3","stage-4","stage-5","stage-6","stage-7","stage-8","stage-9","tdd"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-stage4-bot-traces","title":"Сохранять воспроизводимый журнал команд бота","description":"Добавить versioned trace contract для production-input сценариев с level/format/physics versions, simulation timestamps, inputs и ключевыми checkpoints.","design":"Trace recorder наблюдает публичный Driver/Input контракт и не управляет GameView напрямую; replay engine вне Stage 4 scope, журнал должен быть пригоден для последующего replay.","acceptance_criteria":"Каждый bot runner может выдать deterministic JSON trace; повторная сериализация идентична; timeout diagnostics указывает trace; sensitive/device-local data не записываются.","notes":"GREEN: trace v1 records simulation inputs/checkpoints/outcome plus format/physics versions; SHA-256 trace ID is included in diagnostics; generic and level-8 runners covered.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:04Z","started_at":"2026-08-18T19:01:26Z","closed_at":"2026-08-18T19:01:34Z","close_reason":"Deterministic versioned traces and diagnostic IDs implemented for all bot runner families.","metadata":{"stages":4,"primary_stage":4},"labels":["bot","diagnostics","primary-stage-4","replay","stage-4","stage4"],"dependencies":[{"issue_id":"goon-game-stage4-bot-traces","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:32Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
@@ -35,19 +35,19 @@
{"_type":"issue","id":"goon-game-mobile-qa","title":"Mobile QA matrix","description":"GPU, API, RAM, refresh rate, thermal, background, battery и controller проверки.","acceptance_criteria":"Матрица из раздела 49 заполнена и приложена к release evidence.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:34Z","metadata":{"stages":7,"primary_stage":7},"labels":["android","primary-stage-7","qa","stage-7"],"dependencies":[{"issue_id":"goon-game-mobile-qa","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:41:39Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-mobile-qa","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:53Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-crash-reporting","title":"Crash/ANR reporting без персональных данных","description":"Подключить отчёты ошибок с build/level context и политикой приватности.","acceptance_criteria":"Тестовая ошибка видна в системе; payload не содержит пользовательских секретов.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:38Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:34Z","metadata":{"stages":7,"primary_stage":7},"labels":["analytics","android","primary-stage-7","stage-7"],"dependencies":[{"issue_id":"goon-game-crash-reporting","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-corpse-sleep-pooling","title":"Sleeping corpses и object pooling","description":"Усыплять остановившиеся тела и pool-ить FX/audio/projectiles без изменения головоломок.","acceptance_criteria":"Нет GC spikes; спящие тела корректно пробуждаются внешним воздействием.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:34Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:34Z","metadata":{"stages":7,"primary_stage":7},"labels":["corpse","performance","primary-stage-7","stage-7"],"dependencies":[{"issue_id":"goon-game-corpse-sleep-pooling","depends_on_id":"goon-game-corpse-manager","type":"blocks","created_at":"2026-08-18T00:41:39Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-sleep-pooling","depends_on_id":"goon-game-e13","type":"parent-child","created_at":"2026-08-18T00:43:41Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-sleep-pooling","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:41:40Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-perf-budgets","title":"Зафиксировать performance/physics budgets","description":"Определить бюджеты frame time, bodies, collision pairs, particles, draw calls и memory.","acceptance_criteria":"Метрики собираются воспроизводимо, целевые устройства держат согласованный FPS.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:32Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:10Z","metadata":{"stages":5,"primary_stage":5},"labels":["performance","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-perf-budgets","depends_on_id":"goon-game-e13","type":"parent-child","created_at":"2026-08-18T00:43:39Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0}
{"_type":"issue","id":"goon-game-perf-budgets","title":"Зафиксировать performance/physics budgets","description":"Определить бюджеты frame time, bodies, collision pairs, particles, draw calls и memory.","acceptance_criteria":"Метрики собираются воспроизводимо, целевые устройства держат согласованный FPS.","notes":"Stage 5 RC: published 34ms frame, 12ms update, 20ms draw, 16 bodies, 2500 collision checks, 2200 primitives, 128MiB budgets. Host-GPU Android 16 gate GREEN; SwiftShader p95 41.61ms is transparently documented as non-target.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:32Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:15Z","closed_at":"2026-08-18T23:02:15Z","close_reason":"Reproducible target-device performance and complexity budgets are implemented and pass.","metadata":{"stages":5,"primary_stage":5},"labels":["performance","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-perf-budgets","depends_on_id":"goon-game-e13","type":"parent-child","created_at":"2026-08-18T00:43:39Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0}
{"_type":"issue","id":"goon-game-release-build","title":"Release signing, APK и AAB","description":"Настроить release application ID, защищённый keystore и Google Play Internal Testing bundle.","acceptance_criteria":"Подписанные APK/AAB устанавливаются и проходят smoke-тест; секреты не в репозитории.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:30Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:35Z","metadata":{"stages":7,"primary_stage":7},"labels":["android","primary-stage-7","release","stage-7"],"dependencies":[{"issue_id":"goon-game-release-build","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:38Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-device-matrix","title":"Прогон Android 8–16 и форм-факторов","description":"Проверить API, RAM 2–4 GB, aspect ratios, refresh rates и interruptions.","acceptance_criteria":"Матрица устройств заполнена, blocker/critical дефекты закрыты.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:28Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:35Z","metadata":{"stages":7,"primary_stage":7},"labels":["android","primary-stage-7","qa","stage-7"],"dependencies":[{"issue_id":"goon-game-device-matrix","depends_on_id":"goon-game-android-lifecycle","type":"blocks","created_at":"2026-08-18T00:41:37Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-device-matrix","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:37Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-device-matrix","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:41:38Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-art-vslice","title":"Финальный art/animation pass уровней 1–10","description":"Заменить prototype Canvas-образы финальными персонажами, окружением, UI и анимациями.","acceptance_criteria":"Все игровые состояния читаются визуально; vertical slice готов внешнему тестировщику.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":4800,"created_at":"2026-08-17T21:40:19Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:11Z","metadata":{"stages":5,"primary_stage":5},"labels":["art","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-art-vslice","depends_on_id":"goon-game-art-bible","type":"blocks","created_at":"2026-08-18T00:41:37Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-art-vslice","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:32Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-art-bible","title":"Утвердить самостоятельный Art Bible","description":"Зафиксировать stylized fantasy slapstick, требования к ассетам и границы относительно коммерческого референса.","acceptance_criteria":"Art Bible согласован и не требует копирования защищённого контента.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:17Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:11Z","metadata":{"stages":5,"primary_stage":5},"labels":["art","design","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-art-bible","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:31Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-audio-vslice","title":"Audio pass vertical slice","description":"Добавить UI, movement, mechanisms, deaths, voice variations и музыку без немедленных повторов.","acceptance_criteria":"Все 10 уровней имеют настроенный звук и отдельные volume controls.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:40:15Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:11Z","metadata":{"stages":5,"primary_stage":5},"labels":["audio","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-audio-vslice","depends_on_id":"goon-game-e10","type":"parent-child","created_at":"2026-08-18T00:43:30Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-save-versioning","title":"Версионирование и миграции сохранений","description":"Добавить saveVersion, миграции и тест обновления поверх предыдущего APK.","acceptance_criteria":"Старое сохранение мигрирует без потери завершённых уровней.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:11Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:12Z","metadata":{"stages":5,"primary_stage":5},"labels":["primary-stage-5","save","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-save-versioning","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:27Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-art-vslice","title":"Финальный art/animation pass уровней 1–10","description":"Заменить prototype Canvas-образы финальными персонажами, окружением, UI и анимациями.","acceptance_criteria":"Все игровые состояния читаются визуально; vertical slice готов внешнему тестировщику.","notes":"Stage 5 RC: original Clockwork Catacombs rig, 3 skins, readable alive/run/jump/fall/dead/frozen/conductive/pinned/respawn states; visual smoke and exact 35/35 Android gate GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":4800,"created_at":"2026-08-17T21:40:19Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:31Z","started_at":"2026-08-18T21:41:06Z","closed_at":"2026-08-18T23:01:31Z","close_reason":"Original articulated visual pass is implemented for all ten levels and verified in the final candidate.","metadata":{"stages":5,"primary_stage":5},"labels":["art","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-art-vslice","depends_on_id":"goon-game-art-bible","type":"blocks","created_at":"2026-08-18T00:41:37Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-art-vslice","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:32Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-art-bible","title":"Утвердить самостоятельный Art Bible","description":"Зафиксировать stylized fantasy slapstick, требования к ассетам и границы относительно коммерческого референса.","acceptance_criteria":"Art Bible согласован и не требует копирования защищённого контента.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:17Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:41:05Z","started_at":"2026-08-18T21:40:21Z","closed_at":"2026-08-18T21:41:05Z","close_reason":"Art Bible approved in docs/STAGE5_ART_BIBLE.md: original Clockwork Catacombs direction, articulated skin-ready rig, state readability, IP boundaries and performance constraints.","metadata":{"stages":5,"primary_stage":5},"labels":["art","design","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-art-bible","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:31Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-audio-vslice","title":"Audio pass vertical slice","description":"Добавить UI, movement, mechanisms, deaths, voice variations и музыку без немедленных повторов.","acceptance_criteria":"Все 10 уровней имеют настроенный звук и отдельные volume controls.","notes":"Stage 5 RC: procedural music/effects/voice variants, no immediate repeats, independent persisted Music/Effects/Voice controls; unit and Android persistence gates GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:40:15Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:39Z","closed_at":"2026-08-18T23:01:39Z","close_reason":"All ten levels use the completed procedural audio pass with independent persistent controls.","metadata":{"stages":5,"primary_stage":5},"labels":["audio","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-audio-vslice","depends_on_id":"goon-game-e10","type":"parent-child","created_at":"2026-08-18T00:43:30Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-save-versioning","title":"Версионирование и миграции сохранений","description":"Добавить saveVersion, миграции и тест обновления поверх предыдущего APK.","acceptance_criteria":"Старое сохранение мигрирует без потери завершённых уровней.","notes":"Stage 5 RC: saveVersion 2, safe v1 migration preserving completion, full active puzzle Bundle snapshot and visible-paused restore; GameSessionRestoreTest GREEN in full gate.","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:40:11Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:55Z","closed_at":"2026-08-18T23:01:55Z","close_reason":"Legacy progress migrates without loss and supported active sessions restore safely.","metadata":{"stages":5,"primary_stage":5},"labels":["primary-stage-5","save","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-save-versioning","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:27Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-save-basic","title":"Базовое сохранение прогресса","description":"Сохраняются unlock, best time и минимальное число жертв.","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:09Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:22Z","close_reason":"Локальный прогресс и лучшие результаты сохраняются.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-save-basic","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-accessibility","title":"Accessibility pass","description":"Проверить контраст, масштаб текста, safe areas, режим без вибрации и не-звуковые сигналы.","acceptance_criteria":"Критическая информация доступна визуально, элементы не перекрываются.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:06Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:00Z","started_at":"2026-08-18T13:09:20Z","closed_at":"2026-08-18T16:26:41Z","close_reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10.","metadata":{"stages":3,"primary_stage":3},"labels":["accessibility","primary-stage-3","stage-3","ui"],"dependencies":[{"issue_id":"goon-game-accessibility","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:25Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-menus-localization","title":"Меню, результаты и RU/EN локализация","description":"Добавить main/world/level select, settings, statistics, credits и полноценный result screen.","acceptance_criteria":"Все строки локализованы; игра проходится без объяснений разработчика.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:04Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:00Z","closed_at":"2026-08-18T16:26:43Z","close_reason":"Persistent RU/EN, onboarding and full result flow integrated and verified by final 30/30 gate. External human comprehension playtest remains tracked separately.","metadata":{"stages":3,"primary_stage":3},"labels":["localization","primary-stage-3","stage-3","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-menus-localization","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-controls-settings","title":"Настройки размера, opacity и вибрации controls","description":"Дать игроку настройку touch controls и исключить случайные повторные прыжки.","acceptance_criteria":"Настройки сохраняются и проверены на 16:9, 20:9 и планшете.","notes":"2026-08-18 Stage 3 controls slice complete. RED/GREEN unit contracts cover size/opacity/haptics presets. Production Settings screen changes values through real taps; SharedPreferences survives Activity recreation. Large preset expands real hit target, held jump does not retrigger after landing. Device GREEN: 20:9 class 3/3, logical 16:9 persistence 1/1, tablet-sized 1/1; lifecycle/restart/L5/L10 regressions 4/4; clean 20 JVM tests, lint and app/test APK build.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:01Z","started_at":"2026-08-18T11:28:25Z","closed_at":"2026-08-18T11:43:04Z","close_reason":"Acceptance met: size, opacity and vibration settings persist; real touch targets and held-jump edge are tested; UI contract passes 16:9, 20:9 and tablet-sized displays.","metadata":{"stages":3,"primary_stage":3},"labels":["current","primary-stage-3","stage-3","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-controls-settings","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:23Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"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":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","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-18T21:02:12Z","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-puzzle-events","title":"Выделить event-driven PuzzleSystem","description":"Заменить прямую проверку сигналов универсальными каналами событий и явными состояниями DoorController.","acceptance_criteria":"Кнопки, двери и платформы связываются данными; integration-тесты проходят.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","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}
@@ -58,7 +58,7 @@
{"_type":"issue","id":"goon-game-e13","title":"EPIC-13 Optimization","description":"60 FPS target, object pooling, physics budgets и low profile. Разделы 41–44.","acceptance_criteria":"Производительность укладывается в зафиксированные бюджеты на средних и слабых устройствах.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:10Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:51Z","metadata":{"stages":"5,7","primary_stage":7},"labels":["epic","performance","primary-stage-7","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e13","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:56Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e09","title":"EPIC-09 Save \u0026 Progression","description":"Версионированные сохранения, unlock, challenges, collectibles и статистика. Разделы 20–22, 34.","acceptance_criteria":"Прогресс сохраняется между версиями и корректно мигрирует при обновлении.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:01Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:33Z","metadata":{"stages":"0,5,6","primary_stage":6},"labels":["epic","primary-stage-6","progression","save","stage-0","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e09","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:51Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e08","title":"EPIC-08 UI/UX","description":"Android controls, HUD, меню, выбор уровней и доступность. Разделы 16–17, 31–33, 45.","acceptance_criteria":"Игра полностью понятна и управляема на поддерживаемых экранах без внешних объяснений.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:59Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:29Z","metadata":{"stages":"0,3,6","primary_stage":6},"labels":["epic","primary-stage-6","stage-0","stage-3","stage-6","ui","ux"],"dependencies":[{"issue_id":"goon-game-e08","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e06","title":"EPIC-06 Camera","description":"Следящая камера, look-ahead, damping, bounds и focus targets. Раздел 18.","acceptance_criteria":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:20Z","metadata":{"stages":"0,5","primary_stage":5},"labels":["camera","epic","primary-stage-5","stage-0","stage-5"],"dependencies":[{"issue_id":"goon-game-e06","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e06","title":"EPIC-06 Camera","description":"Следящая камера, look-ahead, damping, bounds и focus targets. Раздел 18.","acceptance_criteria":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","notes":"Stage 5 camera acceptance complete: bounded follow/focus/shake, level bounds and reduced motion; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:03Z","closed_at":"2026-08-18T23:03:03Z","close_reason":"Camera foundation and production polish acceptance are complete.","metadata":{"stages":"0,5","primary_stage":5},"labels":["camera","epic","primary-stage-5","stage-0","stage-5"],"dependencies":[{"issue_id":"goon-game-e06","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e05","title":"EPIC-05 Puzzle Objects","description":"Кнопки, двери, платформы, весовые объекты и событийные сигналы. Разделы 11–14, 37.","acceptance_criteria":"Механизмы не связаны жёсткими ссылками и корректно работают с живым персонажем и телами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:53Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:15Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","primary-stage-6","puzzle","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e05","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e04","title":"EPIC-04 Hazards","description":"Каталог опасностей и универсальный контракт источника смерти. Разделы 5, 10, 38.","acceptance_criteria":"Опасности data-driven, задокументированы и проходят Android/QA проверки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:11Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","hazards","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e04","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:45Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e01","title":"EPIC-01 Character","description":"Персонаж, state machine, движение и platforming. Разделы 3, 17, 28, 36.","acceptance_criteria":"Персонаж соответствует утверждённому набору состояний и стабильно управляется на Android.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:56Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["character","epic","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e01","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
@@ -73,6 +73,6 @@
{"_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-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 без раздражающих повторов.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","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-18T21:03:37Z","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}
{"_type":"issue","id":"goon-game-milestone-rc","title":"Milestone: Release Candidate","description":"Финальный gate раздела 59.","acceptance_criteria":"0 blocker, 0 critical progression bugs, all levels completable, save migration/lifecycle/store signing/analytics/crash reporting verified.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":3,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:11Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:47Z","metadata":{"stages":10,"primary_stage":10},"labels":["milestone","primary-stage-10","release-candidate","stage-10"],"dependencies":[{"issue_id":"goon-game-milestone-rc","depends_on_id":"goon-game-analytics-contract","type":"blocks","created_at":"2026-08-18T00:44:32Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-rc","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:44:00Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-rc","depends_on_id":"goon-game-milestone-beta","type":"blocks","created_at":"2026-08-18T00:44:30Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-rc","depends_on_id":"goon-game-release-build","type":"blocks","created_at":"2026-08-18T00:44:31Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-milestone-beta","title":"Milestone: Beta","description":"Feature freeze, зафиксированные уровни, bug fixing, optimization и localization QA.","acceptance_criteria":"Нет новых механик; beta QA matrix зелёная.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":3,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:06Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:43Z","metadata":{"stages":9,"primary_stage":9},"labels":["beta","milestone","primary-stage-9","stage-9"],"dependencies":[{"issue_id":"goon-game-milestone-beta","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:58Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-beta","depends_on_id":"goon-game-milestone-alpha","type":"blocks","created_at":"2026-08-18T00:44:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
+15 -1
View File
@@ -8,6 +8,10 @@ Project documents:
- [`docs/CURRENT_ROADMAP.md`](docs/CURRENT_ROADMAP.md) — execution roadmap for the existing vertical slice;
- [`docs/LEVEL_SOLUTION_GRAPHS.md`](docs/LEVEL_SOLUTION_GRAPHS.md) — source-backed intended solution graph for every current level;
- [`docs/STAGE4_CONTENT_FORMAT.md`](docs/STAGE4_CONTENT_FORMAT.md) — versioned JSON contract, validator, preview, and bot-trace format;
- [`docs/STAGE5_ART_BIBLE.md`](docs/STAGE5_ART_BIBLE.md) — original visual language and articulated volunteer contract;
- [`docs/STAGE5_PERFORMANCE_BUDGETS.md`](docs/STAGE5_PERFORMANCE_BUDGETS.md) — physics, replay, frame, memory, and complexity ceilings;
- [`docs/STAGE5_QA_REPORT.md`](docs/STAGE5_QA_REPORT.md) — exact Vertical Slice candidate evidence;
- [`docs/STAGE5_HUMAN_PLAYTEST.md`](docs/STAGE5_HUMAN_PLAYTEST.md) — required external acceptance protocol;
- [`docs/STAGE_REGISTRY.md`](docs/STAGE_REGISTRY.md) — mandatory Stage 0–10 ownership for every Bead;
- [`docs/BEADS_WORKFLOW.md`](docs/BEADS_WORKFLOW.md) — local backlog structure and daily Beads commands.
@@ -36,7 +40,8 @@ Death does not reset the level. A new volunteer respawns after a short delay whi
- `II` pauses the game and exposes level selection.
- `Settings` on the level-select screen cycles three control sizes, three
opacity presets, vibration on/off, Standard/Large text, high contrast, and
English/Russian. Changes persist across app restarts without resetting progress.
English/Russian, reduced motion, and independent Music/Effects/Voice volumes.
Changes persist across app restarts without resetting progress.
The first level includes a three-step, non-blocking tutorial for movement,
multitouch jumping, and using retained bodies. Completion shows the current run,
@@ -91,6 +96,15 @@ in one process (`OK (30 tests)`, 1,096.448 seconds). Acceptance bots now attach
versioned deterministic input/checkpoint trace and SHA-256 trace ID to their
result diagnostics.
Stage 5 replaces the prototype knight with an original articulated clockwork
volunteer rig and three data-driven skins, adds procedural audio, camera focus and
shake with reduced motion, save version 2 and active-session restoration. Its exact
Android 16 candidate passed all 35 instrumentation methods in one unfiltered run
(`493.521 s`) plus 72 JVM tests. Deterministic tapes replay all ten levels three
times with identical state signatures. Engineering is complete; formal milestone
acceptance still requires the independent 20-session playtest and is not claimed
from bots.
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:
```sh
+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId 'com.projectsacrifice.game'
minSdk 23
targetSdk 34
versionCode 8
versionName '0.7.0-stage4-content'
versionCode 9
versionName '0.8.0-stage5-vslice'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
@@ -0,0 +1,40 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class AudioSettingsPersistenceTest {
@Test
public void independentAudioVolumesSurviveActivityRecreation() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
view.resetAudioSettingsForAutomation();
view.selectNextMusicVolumeForAutomation();
view.selectNextEffectsVolumeForAutomation();
view.selectNextVoiceVolumeForAutomation();
});
activity.recreate();
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
AudioSettings settings = view.audioSettingsForAutomation();
assertEquals(1f, settings.musicVolume(), 0f);
assertEquals(0f, settings.effectsVolume(), 0f);
assertEquals(0f, settings.voiceVolume(), 0f);
view.openSettingsForAutomation();
assertTrue(view.visibleLocalizedTextForAutomation().contains("Audio"));
assertTrue(view.screenDescriptionForAutomation().contains("Music 100%"));
view.resetAudioSettingsForAutomation();
});
}
}
}
@@ -0,0 +1,87 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.SharedPreferences;
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 GameSessionRestoreTest {
@Test
public void recreationRestoresActivePuzzleBodiesTimersAndPausedSafety() {
GameSessionSnapshot[] before = new GameSessionSnapshot[1];
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameView[] view = new GameView[1];
activity.onActivity(current -> {
view[0] = current.gameViewForAutomation();
view[0].enableDeterministicAutomationClock();
view[0].startLevelForAutomation(5);
view[0].setAutomationInput(false, true, false);
for (int decision = 0; decision < 400
&& view[0].automationSnapshot().deaths() == 0; decision++) {
view[0].advanceDeterministicAutomation(16L);
}
assertEquals(1, view[0].automationSnapshot().deaths());
assertEquals(1, view[0].automationSnapshot().corpseCount());
view[0].setAutomationInput(false, false, false);
before[0] = view[0].sessionSnapshotForAutomation();
});
activity.recreate();
activity.onActivity(current -> {
view[0] = current.gameViewForAutomation();
GameSessionSnapshot after = view[0].sessionSnapshotForAutomation();
assertEquals(before[0].levelNumber(), after.levelNumber());
assertEquals(before[0].deaths(), after.deaths());
assertEquals(before[0].elapsedSeconds(), after.elapsedSeconds(), 0.001f);
assertEquals(before[0].player().x(), after.player().x(), 0.001f);
assertEquals(before[0].player().alive(), after.player().alive());
assertEquals(before[0].bodies().size(), after.bodies().size());
assertEquals(before[0].bodies().get(0).deathType(),
after.bodies().get(0).deathType());
assertEquals(before[0].platformMotionTimes(), after.platformMotionTimes());
assertEquals(before[0].timedSignals(), after.timedSignals());
assertTrue(view[0].automationSnapshot().visiblyPaused());
assertFalse(view[0].automationSnapshot().inputActive());
current.onBackPressed();
view[0].enableDeterministicAutomationClock();
view[0].advanceDeterministicAutomation(1_000L);
assertTrue(view[0].automationSnapshot().playerAlive());
});
}
}
@Test
public void legacyProgressMigratesInPlaceWithoutLosingCompletion() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
SharedPreferences legacy = context.getSharedPreferences(
"project_sacrifice_progress_v1", Context.MODE_PRIVATE);
legacy.edit().clear()
.putInt("unlocked", 6)
.putBoolean("level_5_completed", true)
.putInt("level_5_deaths", 3)
.putLong("level_5_time", 42_000L)
.commit();
ProgressStore migrated = new ProgressStore(context);
assertEquals(ProgressStore.CURRENT_SAVE_VERSION, migrated.saveVersion());
assertEquals(6, migrated.unlockedCount());
assertTrue(migrated.isCompleted(5));
assertEquals(3, migrated.bestDeaths(5));
assertEquals(42_000L, migrated.bestTimeMs(5));
legacy.edit().clear().commit();
}
}
@@ -0,0 +1,178 @@
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 StageFiveDeterminismTest {
@Test
public void allTenRecordedRoutesReplayThreeSequentialTimesUnderFiveMinutes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameView[] view = new GameView[1];
activity.onActivity(current -> {
view[0] = current.gameViewForAutomation();
view[0].enableDeterministicAutomationClock();
});
DeterministicDriver driver = new DeterministicDriver(
InstrumentationRegistry.getInstrumentation(), view[0]);
LevelPlaytestBot bot = new LevelPlaytestBot();
DeterministicInputTape[] tapes = new DeterministicInputTape[10];
String[] signatures = new String[10];
tapes[0] = record(bot, driver, EarlyLevelScenarios.levelOne(), 20_000L, 0, 0);
signatures[0] = driver.terminalSignature();
tapes[1] = record(bot, driver, EarlyLevelScenarios.levelTwo(), 30_000L, 3, 3);
signatures[1] = driver.terminalSignature();
tapes[2] = record(bot, driver, EarlyLevelScenarios.levelThree(), 25_000L, 1, 1);
signatures[2] = driver.terminalSignature();
tapes[3] = record(bot, driver, EarlyLevelScenarios.levelFour(), 30_000L, 0, 0);
signatures[3] = driver.terminalSignature();
BotReplayRecorder levelFive = new BotReplayRecorder(driver);
LevelPlaytestBot.Result levelFiveResult = bot.proveLevelFive(levelFive, 25_000L);
assertStandardResult(5, levelFiveResult, 1, 1);
tapes[4] = levelFive.tape();
signatures[4] = driver.terminalSignature();
tapes[5] = record(bot, driver, LateLevelScenarios.levelSix(), 30_000L, 1, 1);
signatures[5] = driver.terminalSignature();
tapes[6] = record(bot, driver, LateLevelScenarios.levelSeven(), 25_000L, 1, 1);
signatures[6] = driver.terminalSignature();
BotReplayRecorder levelEight = new BotReplayRecorder(driver);
LevelEightScenario.Result levelEightResult =
new LevelEightScenario().completeIntendedRoute(levelEight, 30_000L);
assertTrue(levelEightResult.diagnostic(), levelEightResult.completed());
assertEquals(0, levelEightResult.deaths());
tapes[7] = levelEight.tape();
signatures[7] = driver.terminalSignature();
tapes[8] = record(bot, driver, LateLevelScenarios.levelNine(), 35_000L, 2, 2);
signatures[8] = driver.terminalSignature();
tapes[9] = record(bot, driver, LateLevelScenarios.levelTen(), 70_000L, 3, 3);
signatures[9] = driver.terminalSignature();
for (int pass = 1; pass <= 3; pass++) {
long passStartedAt = System.currentTimeMillis();
for (int level = 0; level < tapes.length; level++) {
String replayed = tapes[level].play(driver);
assertEquals("pass=" + pass + ", level=" + (level + 1)
+ ", tape=" + tapes[level].id(),
signatures[level], replayed);
assertTrue("pass=" + pass + ", level=" + (level + 1),
driver.snapshot().completed());
}
long elapsed = System.currentTimeMillis() - passStartedAt;
assertTrue("Replay matrix " + pass + " exceeded 5 minutes: " + elapsed,
elapsed < 300_000L);
}
}
}
private static DeterministicInputTape record(
LevelPlaytestBot bot, DeterministicDriver driver,
LevelPlaytestBot.Scenario scenario, long budget,
int expectedDeaths, int expectedBodies) {
BotReplayRecorder recorder = new BotReplayRecorder(driver);
LevelPlaytestBot.Result result = bot.run(recorder, scenario, budget);
DeterministicInputTape tape = recorder.tape();
assertStandardResult(tape.levelNumber(), result, expectedDeaths, expectedBodies);
return tape;
}
private static void assertStandardResult(int level, LevelPlaytestBot.Result result,
int expectedDeaths, int expectedBodies) {
assertTrue("level=" + level + "; " + result.diagnostic(), result.completed());
assertEquals("level=" + level + "; deaths", expectedDeaths, result.deaths());
assertEquals("level=" + level + "; bodies", expectedBodies, result.corpseCount());
}
private static final class DeterministicDriver implements LevelPlaytestBot.Driver,
DeterministicInputTape.Target {
private final Instrumentation instrumentation;
private final GameView gameView;
private long simulationClockMillis;
private boolean pendingLeft;
private boolean pendingRight;
private boolean pendingJump;
private boolean inputDirty;
private GameView.AutomationSnapshot cachedSnapshot;
DeterministicDriver(Instrumentation instrumentation, GameView gameView) {
this.instrumentation = instrumentation;
this.gameView = gameView;
}
@Override
public void startLevel(int oneBasedLevelNumber) {
simulationClockMillis = 0L;
instrumentation.runOnMainSync(() -> {
gameView.enableDeterministicAutomationClock();
gameView.startLevelForAutomation(oneBasedLevelNumber);
cachedSnapshot = gameView.automationSnapshot();
});
pendingLeft = pendingRight = pendingJump = false;
inputDirty = false;
}
@Override
public void setInput(boolean left, boolean right, boolean jump) {
pendingLeft = left;
pendingRight = right;
pendingJump = jump;
inputDirty = true;
}
@Override
public GameView.AutomationSnapshot snapshot() {
if (inputDirty) {
instrumentation.runOnMainSync(() -> {
gameView.setAutomationInput(pendingLeft, pendingRight, pendingJump);
cachedSnapshot = gameView.automationSnapshot();
});
inputDirty = false;
}
return cachedSnapshot;
}
@Override
public void waitForNextDecision(long millis) { advance(millis); }
@Override
public void advance(long milliseconds) {
simulationClockMillis += milliseconds;
instrumentation.runOnMainSync(() -> {
if (inputDirty) {
gameView.setAutomationInput(pendingLeft, pendingRight, pendingJump);
inputDirty = false;
}
gameView.advanceDeterministicAutomation(milliseconds);
cachedSnapshot = gameView.automationSnapshot();
});
}
@Override
public long currentTimeMillis() { return simulationClockMillis; }
@Override
public String terminalSignature() {
String[] signature = new String[1];
instrumentation.runOnMainSync(() -> {
if (inputDirty) {
gameView.setAutomationInput(pendingLeft, pendingRight, pendingJump);
inputDirty = false;
}
cachedSnapshot = gameView.automationSnapshot();
signature[0] = gameView.deterministicStateSignatureForAutomation();
});
return signature[0];
}
}
}
@@ -0,0 +1,41 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertTrue;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class StageFivePerformanceTest {
@Test
public void levelTenRendererPublishesAndMeetsTheReleaseBudget() throws Exception {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameView[] view = new GameView[1];
activity.onActivity(current -> {
view[0] = current.gameViewForAutomation();
view[0].startLevelForAutomation(10);
});
// Activity launch includes landscape rotation and shader warm-up. Cold-start has a
// separate release gate; this contract measures steady-state gameplay rendering.
Thread.sleep(5_000L);
activity.onActivity(current -> view[0].resetPerformanceMetricsForAutomation());
Thread.sleep(10_000L);
PerformanceMonitor.Snapshot[] measured = new PerformanceMonitor.Snapshot[1];
activity.onActivity(current -> measured[0] =
view[0].performanceSnapshotForAutomation());
PerformanceMonitor.Snapshot metrics = measured[0];
assertTrue("At least 180 uninterrupted frames are required; samples="
+ metrics.sampleCount(), metrics.sampleCount() >= 180);
PerformanceBudget budget = PerformanceBudget.release();
assertTrue(metrics.failureSummary(budget), metrics.within(budget));
assertTrue("Target FPS must be explicit", budget.targetFramesPerSecond() >= 60);
assertTrue("Runtime body cap must fit the published budget",
PhysicsConfig.release().maximumBodies() <= budget.maximumBodies());
}
}
}
@@ -47,6 +47,11 @@ final class AccessibilityScreenDescription {
return strings.settingsDescription(controls, accessibility);
}
static String settings(GameStrings strings, ControlSettings controls,
AccessibilitySettings accessibility, AudioSettings audio) {
return strings.settingsDescription(controls, accessibility, audio);
}
static String playing(GameStrings strings, LevelDefinition level, int deaths,
String elapsed, String criticalState, boolean paused) {
return strings.playingDescription(level.number, deaths, elapsed, criticalState, paused);
@@ -16,21 +16,29 @@ public final class AccessibilitySettings {
private final TextSize textSize;
private final boolean highContrastEnabled;
private final boolean reducedMotionEnabled;
private AccessibilitySettings(TextSize textSize, boolean highContrastEnabled) {
private AccessibilitySettings(TextSize textSize, boolean highContrastEnabled,
boolean reducedMotionEnabled) {
this.textSize = textSize;
this.highContrastEnabled = highContrastEnabled;
this.reducedMotionEnabled = reducedMotionEnabled;
}
public static AccessibilitySettings defaults() {
return new AccessibilitySettings(TextSize.STANDARD, false);
return new AccessibilitySettings(TextSize.STANDARD, false, false);
}
static AccessibilitySettings restore(int textSizeIndex, boolean highContrastEnabled) {
return restore(textSizeIndex, highContrastEnabled, false);
}
static AccessibilitySettings restore(int textSizeIndex, boolean highContrastEnabled,
boolean reducedMotionEnabled) {
TextSize[] values = TextSize.values();
TextSize restored = textSizeIndex >= 0 && textSizeIndex < values.length
? values[textSizeIndex] : TextSize.STANDARD;
return new AccessibilitySettings(restored, highContrastEnabled);
return new AccessibilitySettings(restored, highContrastEnabled, reducedMotionEnabled);
}
public String textSizeLabel() {
@@ -48,7 +56,7 @@ public final class AccessibilitySettings {
public AccessibilitySettings nextTextSize() {
TextSize[] values = TextSize.values();
return new AccessibilitySettings(values[(textSize.ordinal() + 1) % values.length],
highContrastEnabled);
highContrastEnabled, reducedMotionEnabled);
}
public boolean highContrastEnabled() {
@@ -60,7 +68,15 @@ public final class AccessibilitySettings {
}
public AccessibilitySettings toggleHighContrast() {
return new AccessibilitySettings(textSize, !highContrastEnabled);
return new AccessibilitySettings(textSize, !highContrastEnabled, reducedMotionEnabled);
}
public boolean reducedMotionEnabled() { return reducedMotionEnabled; }
public String reducedMotionLabel() { return reducedMotionEnabled ? "On" : "Off"; }
public AccessibilitySettings toggleReducedMotion() {
return new AccessibilitySettings(textSize, highContrastEnabled, !reducedMotionEnabled);
}
int textSizeIndex() {
@@ -15,7 +15,8 @@ final class AccessibilitySettingsStore {
AccessibilitySettings defaults = AccessibilitySettings.defaults();
return AccessibilitySettings.restore(
preferences.getInt("text_size", defaults.textSizeIndex()),
preferences.getBoolean("high_contrast", defaults.highContrastEnabled())
preferences.getBoolean("high_contrast", defaults.highContrastEnabled()),
preferences.getBoolean("reduced_motion", defaults.reducedMotionEnabled())
);
}
@@ -23,6 +24,7 @@ final class AccessibilitySettingsStore {
preferences.edit()
.putInt("text_size", settings.textSizeIndex())
.putBoolean("high_contrast", settings.highContrastEnabled())
.putBoolean("reduced_motion", settings.reducedMotionEnabled())
.apply();
}
}
@@ -0,0 +1,28 @@
package com.projectsacrifice.game;
/** Semantic audio events; Android synthesis is an implementation detail. */
public enum AudioCue {
UI_TAP(Channel.EFFECTS, 1),
FOOTSTEP(Channel.EFFECTS, 3),
JUMP(Channel.EFFECTS, 2),
SWITCH(Channel.EFFECTS, 3),
DOOR(Channel.EFFECTS, 2),
DEATH_IMPACT(Channel.EFFECTS, 5),
DEATH_VOICE(Channel.VOICE, 4),
RESPAWN_VOICE(Channel.VOICE, 3),
GOAL_VOICE(Channel.VOICE, 3),
MUSIC_PULSE(Channel.MUSIC, 4);
public enum Channel { MUSIC, EFFECTS, VOICE }
private final Channel channel;
private final int variants;
AudioCue(Channel channel, int variants) {
this.channel = channel;
this.variants = variants;
}
public Channel channel() { return channel; }
public int variants() { return variants; }
}
@@ -0,0 +1,24 @@
package com.projectsacrifice.game;
import java.util.Arrays;
/** Small deterministic variant chooser with independent no-repeat history per cue. */
public final class AudioCueSequencer {
private final int[] previous = new int[AudioCue.values().length];
private long state;
public AudioCueSequencer(long seed) {
Arrays.fill(previous, -1);
state = seed == 0L ? 0x6d2b79f5L : seed;
}
public int nextVariant(AudioCue cue) {
if (cue == null || cue.variants() <= 1) return 0;
state = state * 6364136223846793005L + 1442695040888963407L;
int candidate = (int) Math.floorMod(state >>> 24, cue.variants());
int index = cue.ordinal();
if (candidate == previous[index]) candidate = (candidate + 1) % cue.variants();
previous[index] = candidate;
return candidate;
}
}
@@ -0,0 +1,59 @@
package com.projectsacrifice.game;
/** Immutable independent volume presets for procedural game audio. */
public final class AudioSettings {
private static final float[] VOLUMES = {0f, 0.33f, 0.66f, 1f};
private static final int DEFAULT_MUSIC = 2;
private static final int DEFAULT_EFFECTS = 3;
private static final int DEFAULT_VOICE = 3;
private final int musicPreset;
private final int effectsPreset;
private final int voicePreset;
private AudioSettings(int musicPreset, int effectsPreset, int voicePreset) {
this.musicPreset = musicPreset;
this.effectsPreset = effectsPreset;
this.voicePreset = voicePreset;
}
public static AudioSettings defaults() {
return new AudioSettings(DEFAULT_MUSIC, DEFAULT_EFFECTS, DEFAULT_VOICE);
}
public static AudioSettings restore(int musicPreset, int effectsPreset, int voicePreset) {
return new AudioSettings(valid(musicPreset, DEFAULT_MUSIC),
valid(effectsPreset, DEFAULT_EFFECTS), valid(voicePreset, DEFAULT_VOICE));
}
public int musicPreset() { return musicPreset; }
public int effectsPreset() { return effectsPreset; }
public int voicePreset() { return voicePreset; }
public float musicVolume() { return VOLUMES[musicPreset]; }
public float effectsVolume() { return VOLUMES[effectsPreset]; }
public float voiceVolume() { return VOLUMES[voicePreset]; }
public AudioSettings nextMusicVolume() {
return new AudioSettings(next(musicPreset), effectsPreset, voicePreset);
}
public AudioSettings nextEffectsVolume() {
return new AudioSettings(musicPreset, next(effectsPreset), voicePreset);
}
public AudioSettings nextVoiceVolume() {
return new AudioSettings(musicPreset, effectsPreset, next(voicePreset));
}
public String volumeLabel(int preset) {
return Math.round(VOLUMES[valid(preset, 0)] * 100f) + "%";
}
private static int next(int preset) {
return (preset + 1) % VOLUMES.length;
}
private static int valid(int preset, int fallback) {
return preset >= 0 && preset < VOLUMES.length ? preset : fallback;
}
}
@@ -0,0 +1,29 @@
package com.projectsacrifice.game;
import android.content.Context;
import android.content.SharedPreferences;
final class AudioSettingsStore {
private static final String FILE = "project_sacrifice_audio_settings_v1";
private final SharedPreferences preferences;
AudioSettingsStore(Context context) {
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
}
AudioSettings load() {
AudioSettings defaults = AudioSettings.defaults();
return AudioSettings.restore(
preferences.getInt("music", defaults.musicPreset()),
preferences.getInt("effects", defaults.effectsPreset()),
preferences.getInt("voice", defaults.voicePreset()));
}
void save(AudioSettings settings) {
preferences.edit()
.putInt("music", settings.musicPreset())
.putInt("effects", settings.effectsPreset())
.putInt("voice", settings.voicePreset())
.apply();
}
}
@@ -0,0 +1,48 @@
package com.projectsacrifice.game;
/** Records the exact production-input waits emitted by a black-box playtest bot. */
public final class BotReplayRecorder implements LevelPlaytestBot.Driver {
private final LevelPlaytestBot.Driver delegate;
private DeterministicInputTape.Builder tape;
private boolean left;
private boolean right;
private boolean jump;
public BotReplayRecorder(LevelPlaytestBot.Driver delegate) {
if (delegate == null) throw new IllegalArgumentException("Driver is required");
this.delegate = delegate;
}
@Override
public void startLevel(int oneBasedLevelNumber) {
tape = DeterministicInputTape.builder(oneBasedLevelNumber);
left = right = jump = false;
delegate.startLevel(oneBasedLevelNumber);
}
@Override
public void setInput(boolean moveLeft, boolean moveRight, boolean jumpPressed) {
left = moveLeft;
right = moveRight;
jump = jumpPressed;
delegate.setInput(moveLeft, moveRight, jumpPressed);
}
@Override
public GameView.AutomationSnapshot snapshot() { return delegate.snapshot(); }
@Override
public void waitForNextDecision(long millis) {
if (tape == null) throw new IllegalStateException("A level must start before recording");
tape.record(left, right, jump, millis);
delegate.waitForNextDecision(millis);
}
@Override
public long currentTimeMillis() { return delegate.currentTimeMillis(); }
public DeterministicInputTape tape() {
if (tape == null) throw new IllegalStateException("No replay recorded");
return tape.build();
}
}
@@ -0,0 +1,114 @@
package com.projectsacrifice.game;
/** Deterministic, allocation-free camera follow/focus/shake state. */
public final class CameraController {
public static final float MAX_FOCUS_OFFSET = 420f;
public static final float MAX_SHAKE = 24f;
public static final class Frame {
private float x;
private float y;
private float shakeX;
private float shakeY;
private float focusOffsetX;
private boolean focusActive;
public float x() { return x; }
public float y() { return y; }
public float shakeX() { return shakeX; }
public float shakeY() { return shakeY; }
public float focusOffsetX() { return focusOffsetX; }
public boolean focusActive() { return focusActive; }
}
private static final float SHAKE_SECONDS = 0.28f;
private final float viewportWidth;
private final float viewportHeight;
private final Frame frame = new Frame();
private float focusX;
private float focusY;
private float focusRemaining;
private float shakeStrength;
private float shakeRemaining;
private float shakePhase;
public CameraController(float viewportWidth, float viewportHeight) {
if (viewportWidth <= 0f || viewportHeight <= 0f) {
throw new IllegalArgumentException("Camera viewport must be positive");
}
this.viewportWidth = viewportWidth;
this.viewportHeight = viewportHeight;
}
public void reset() {
frame.x = 0f;
frame.y = 0f;
frame.shakeX = 0f;
frame.shakeY = 0f;
frame.focusOffsetX = 0f;
frame.focusActive = false;
focusRemaining = 0f;
shakeStrength = 0f;
shakeRemaining = 0f;
shakePhase = 0f;
}
public void requestFocus(float worldX, float worldY, float durationSeconds) {
focusX = worldX;
focusY = worldY;
focusRemaining = Math.max(0f, durationSeconds);
}
public void impact(float strength) {
shakeStrength = Math.max(shakeStrength, Math.min(MAX_SHAKE, Math.max(0f, strength)));
shakeRemaining = shakeStrength > 0f ? SHAKE_SECONDS : 0f;
}
public Frame update(float dt, float playerX, float playerY, int facing,
float worldWidth, float worldHeight, boolean reducedMotion) {
float safeDt = Math.max(0f, dt);
focusRemaining = Math.max(0f, focusRemaining - safeDt);
frame.focusActive = focusRemaining > 0f;
float targetX = playerX - 610f + (facing < 0 ? -110f : 110f);
float targetY = playerY - viewportHeight * 0.48f;
frame.focusOffsetX = 0f;
if (frame.focusActive) {
frame.focusOffsetX = clamp(focusX - playerX,
-MAX_FOCUS_OFFSET, MAX_FOCUS_OFFSET);
targetX += frame.focusOffsetX;
targetY += clamp(focusY - playerY,
-MAX_FOCUS_OFFSET * 0.5f, MAX_FOCUS_OFFSET * 0.5f);
}
float maxX = Math.max(0f, worldWidth - viewportWidth);
float maxY = Math.max(0f, worldHeight - viewportHeight);
targetX = clamp(targetX, 0f, maxX);
targetY = clamp(targetY, 0f, maxY);
float horizontalBlend = 1f - (float) Math.exp(-safeDt * 4.5f);
float verticalBlend = 1f - (float) Math.exp(-safeDt * 3.1f);
frame.x = clamp(frame.x + (targetX - frame.x) * horizontalBlend, 0f, maxX);
frame.y = clamp(frame.y + (targetY - frame.y) * verticalBlend, 0f, maxY);
shakeRemaining = Math.max(0f, shakeRemaining - safeDt);
if (reducedMotion || shakeRemaining <= 0f) {
frame.shakeX = 0f;
frame.shakeY = 0f;
if (shakeRemaining <= 0f) shakeStrength = 0f;
} else {
shakePhase += safeDt * 71f;
float decay = shakeRemaining / SHAKE_SECONDS;
frame.shakeX = (float) Math.sin(shakePhase) * shakeStrength * decay;
frame.shakeY = (float) Math.cos(shakePhase * 1.37f) * shakeStrength * decay * 0.65f;
}
return frame;
}
public Frame frame() {
return frame;
}
private static float clamp(float value, float minimum, float maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
}
@@ -0,0 +1,116 @@
package com.projectsacrifice.game;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Compressed production-input command stream replayed against a fixed-step target. */
public final class DeterministicInputTape {
public interface Target {
void startLevel(int oneBasedLevelNumber);
void setInput(boolean left, boolean right, boolean jump);
void advance(long milliseconds);
String terminalSignature();
}
public static final class Command {
private final boolean left;
private final boolean right;
private final boolean jump;
private final long milliseconds;
private Command(boolean left, boolean right, boolean jump, long milliseconds) {
this.left = left;
this.right = right;
this.jump = jump;
this.milliseconds = milliseconds;
}
public boolean left() { return left; }
public boolean right() { return right; }
public boolean jump() { return jump; }
public long milliseconds() { return milliseconds; }
}
public static final class Builder {
private final int levelNumber;
private final ArrayList<Command> commands = new ArrayList<>();
private Builder(int levelNumber) {
if (levelNumber < 1 || levelNumber > 10) {
throw new IllegalArgumentException("Unknown level: " + levelNumber);
}
this.levelNumber = levelNumber;
}
public Builder record(boolean left, boolean right, boolean jump, long milliseconds) {
if (milliseconds <= 0L) throw new IllegalArgumentException("Duration must be positive");
if (!commands.isEmpty()) {
Command previous = commands.get(commands.size() - 1);
if (previous.left == left && previous.right == right && previous.jump == jump) {
commands.set(commands.size() - 1, new Command(left, right, jump,
Math.addExact(previous.milliseconds, milliseconds)));
return this;
}
}
commands.add(new Command(left, right, jump, milliseconds));
return this;
}
public DeterministicInputTape build() {
return new DeterministicInputTape(levelNumber, commands);
}
}
private final int levelNumber;
private final List<Command> commands;
private final String id;
private DeterministicInputTape(int levelNumber, List<Command> commands) {
this.levelNumber = levelNumber;
this.commands = Collections.unmodifiableList(new ArrayList<>(commands));
id = digest(levelNumber, commands);
}
public static Builder builder(int levelNumber) { return new Builder(levelNumber); }
public int levelNumber() { return levelNumber; }
public List<Command> commands() { return commands; }
public String id() { return id; }
public String play(Target target) {
if (target == null) throw new IllegalArgumentException("Replay target is required");
target.startLevel(levelNumber);
for (Command command : commands) {
target.setInput(command.left, command.right, command.jump);
target.advance(command.milliseconds);
}
return target.terminalSignature();
}
private static String digest(int levelNumber, List<Command> commands) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update((byte) levelNumber);
for (Command command : commands) {
digest.update((byte) (command.left ? 1 : 0));
digest.update((byte) (command.right ? 1 : 0));
digest.update((byte) (command.jump ? 1 : 0));
digest.update(Long.toString(command.milliseconds)
.getBytes(StandardCharsets.US_ASCII));
digest.update((byte) 0xff);
}
byte[] bytes = digest.digest();
StringBuilder hex = new StringBuilder(24);
for (int i = 0; i < 12; i++) {
hex.append(Character.forDigit((bytes[i] >>> 4) & 0xf, 16));
hex.append(Character.forDigit(bytes[i] & 0xf, 16));
}
return hex.toString();
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 unavailable", impossible);
}
}
}
@@ -0,0 +1,168 @@
package com.projectsacrifice.game;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Immutable puzzle-critical state used by versioned activity restoration. */
public final class GameSessionSnapshot {
public static final class PlayerData {
private final float x, y, velocityX, velocityY;
private final boolean alive, onGround;
private final float coyoteSeconds, jumpBufferSeconds;
private final int supportIndex, facing;
public PlayerData(float x, float y, float velocityX, float velocityY,
boolean alive, boolean onGround, float coyoteSeconds,
float jumpBufferSeconds, int supportIndex, int facing) {
this.x = x;
this.y = y;
this.velocityX = velocityX;
this.velocityY = velocityY;
this.alive = alive;
this.onGround = onGround;
this.coyoteSeconds = coyoteSeconds;
this.jumpBufferSeconds = jumpBufferSeconds;
this.supportIndex = supportIndex;
this.facing = facing < 0 ? -1 : 1;
}
public float x() { return x; }
public float y() { return y; }
public float velocityX() { return velocityX; }
public float velocityY() { return velocityY; }
public boolean alive() { return alive; }
public boolean onGround() { return onGround; }
public float coyoteSeconds() { return coyoteSeconds; }
public float jumpBufferSeconds() { return jumpBufferSeconds; }
public int supportIndex() { return supportIndex; }
public int facing() { return facing; }
}
public static final class BodyData {
private final float x, y, width, height, velocityX, velocityY;
private final boolean frozen, conductive, pinned, grounded;
private final int supportIndex;
private final String deathType;
public BodyData(float x, float y, float width, float height,
float velocityX, float velocityY, boolean frozen,
boolean conductive, boolean pinned, boolean grounded,
int supportIndex, String deathType) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.velocityX = velocityX;
this.velocityY = velocityY;
this.frozen = frozen;
this.conductive = conductive;
this.pinned = pinned;
this.grounded = grounded;
this.supportIndex = supportIndex;
this.deathType = deathType == null ? "SPIKE" : deathType;
}
public float x() { return x; }
public float y() { return y; }
public float width() { return width; }
public float height() { return height; }
public float velocityX() { return velocityX; }
public float velocityY() { return velocityY; }
public boolean frozen() { return frozen; }
public boolean conductive() { return conductive; }
public boolean pinned() { return pinned; }
public boolean grounded() { return grounded; }
public int supportIndex() { return supportIndex; }
public String deathType() { return deathType; }
}
public static final class Builder {
private int saveVersion = VersionedSessionCodec.CURRENT_VERSION;
private final int levelNumber;
private float elapsedSeconds;
private int deaths;
private float respawnSeconds;
private PlayerData player = new PlayerData(
0f, 0f, 0f, 0f, true, false, 0f, 0f, -1, 1);
private final ArrayList<BodyData> bodies = new ArrayList<>();
private final ArrayList<Float> platformMotionTimes = new ArrayList<>();
private final LinkedHashMap<String, Boolean> toggleSignals = new LinkedHashMap<>();
private final LinkedHashMap<String, Float> timedSignals = new LinkedHashMap<>();
private final LinkedHashMap<String, Boolean> previousSwitchContacts =
new LinkedHashMap<>();
private Builder(int levelNumber) {
this.levelNumber = levelNumber;
}
Builder saveVersion(int version) { saveVersion = version; return this; }
public Builder elapsedSeconds(float value) { elapsedSeconds = value; return this; }
public Builder deaths(int value) { deaths = value; return this; }
public Builder respawnSeconds(float value) { respawnSeconds = value; return this; }
public Builder player(PlayerData value) { player = value; return this; }
public Builder addBody(BodyData value) { bodies.add(value); return this; }
public Builder platformMotionTimes(List<Float> values) {
platformMotionTimes.clear();
if (values != null) platformMotionTimes.addAll(values);
return this;
}
public Builder toggleSignal(String key, boolean value) {
toggleSignals.put(key, value);
return this;
}
public Builder timedSignal(String key, float value) {
timedSignals.put(key, value);
return this;
}
public Builder previousSwitchContact(String key, boolean value) {
previousSwitchContacts.put(key, value);
return this;
}
public GameSessionSnapshot build() { return new GameSessionSnapshot(this); }
}
private final int saveVersion;
private final int levelNumber;
private final float elapsedSeconds;
private final int deaths;
private final float respawnSeconds;
private final PlayerData player;
private final List<BodyData> bodies;
private final List<Float> platformMotionTimes;
private final Map<String, Boolean> toggleSignals;
private final Map<String, Float> timedSignals;
private final Map<String, Boolean> previousSwitchContacts;
private GameSessionSnapshot(Builder builder) {
saveVersion = builder.saveVersion;
levelNumber = builder.levelNumber;
elapsedSeconds = builder.elapsedSeconds;
deaths = builder.deaths;
respawnSeconds = builder.respawnSeconds;
player = builder.player;
bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies));
platformMotionTimes = Collections.unmodifiableList(
new ArrayList<>(builder.platformMotionTimes));
toggleSignals = Collections.unmodifiableMap(
new LinkedHashMap<>(builder.toggleSignals));
timedSignals = Collections.unmodifiableMap(new LinkedHashMap<>(builder.timedSignals));
previousSwitchContacts = Collections.unmodifiableMap(
new LinkedHashMap<>(builder.previousSwitchContacts));
}
public static Builder builder(int levelNumber) { return new Builder(levelNumber); }
public int saveVersion() { return saveVersion; }
public int levelNumber() { return levelNumber; }
public float elapsedSeconds() { return elapsedSeconds; }
public int deaths() { return deaths; }
public float respawnSeconds() { return respawnSeconds; }
public PlayerData player() { return player; }
public List<BodyData> bodies() { return bodies; }
public List<Float> platformMotionTimes() { return platformMotionTimes; }
public Map<String, Boolean> toggleSignals() { return toggleSignals; }
public Map<String, Float> timedSignals() { return timedSignals; }
public Map<String, Boolean> previousSwitchContacts() { return previousSwitchContacts; }
}
@@ -79,6 +79,15 @@ public final class GameStrings {
public String highContrastValue(boolean enabled) {
return hapticsValue(enabled);
}
public String reducedMotionSetting() { return russian() ? "Меньше движения" : "Reduced motion"; }
public String reducedMotionValue(boolean enabled) { return hapticsValue(enabled); }
public String audioTitle() { return russian() ? "ЗВУК" : "Audio"; }
public String musicSetting() { return russian() ? "Музыка" : "Music"; }
public String effectsSetting() { return russian() ? "Эффекты" : "Effects"; }
public String voiceSetting() { return russian() ? "Голоса" : "Voice"; }
public String volumeValue(AudioSettings settings, int preset) {
return settings.volumeLabel(preset);
}
public String back() { return russian() ? "Назад" : "Back"; }
public String hudStats(int deaths, long elapsedMilliseconds) {
@@ -222,6 +231,12 @@ public final class GameStrings {
public String settingsDescription(ControlSettings controls,
AccessibilitySettings accessibility) {
return settingsDescription(controls, accessibility, AudioSettings.defaults());
}
public String settingsDescription(ControlSettings controls,
AccessibilitySettings accessibility,
AudioSettings audio) {
if (russian()) {
return "Настройки. Размер управления "
+ controlSizeValue(controls.controlSizeIndex())
@@ -230,6 +245,11 @@ public final class GameStrings {
+ ". Вибрация " + hapticsValue(controls.hapticsEnabled())
+ ". Размер текста " + textSizeValue(accessibility.textScale())
+ ". Контраст " + highContrastValue(accessibility.highContrastEnabled())
+ ". Меньше движения "
+ reducedMotionValue(accessibility.reducedMotionEnabled())
+ ". Музыка " + volumeValue(audio, audio.musicPreset())
+ ". Эффекты " + volumeValue(audio, audio.effectsPreset())
+ ". Голоса " + volumeValue(audio, audio.voicePreset())
+ ". Назад.";
}
return "Settings. Control size " + controls.controlSizeLabel()
@@ -237,6 +257,10 @@ public final class GameStrings {
+ ". Vibration " + controls.hapticsLabel()
+ ". Text size " + accessibility.textSizeLabel()
+ ". High contrast " + accessibility.highContrastLabel()
+ ". Reduced motion " + accessibility.reducedMotionLabel()
+ ". Music " + volumeValue(audio, audio.musicPreset())
+ ". Effects " + volumeValue(audio, audio.effectsPreset())
+ ". Voice " + volumeValue(audio, audio.voicePreset())
+ ". Back.";
}
@@ -28,17 +28,18 @@ import java.util.Map;
* The Canvas design keeps the prototype small enough for direct APK distribution.
*/
public final class GameView extends View implements Runnable {
private static final PhysicsConfig PHYSICS = PhysicsConfig.release();
private static final float VIEW_WIDTH = 1920f;
private static final float VIEW_HEIGHT = 1080f;
private static final float PLAYER_WIDTH = 62f;
private static final float PLAYER_HEIGHT = 100f;
private static final float GRAVITY = 1800f;
private static final float RUN_SPEED = 370f;
private static final float JUMP_SPEED = 880f;
private static final float COYOTE_SECONDS = 0.13f;
private static final float JUMP_BUFFER_SECONDS = 0.14f;
private static final float RESPAWN_SECONDS = 0.78f;
private static final int MAX_CORPSES = 16;
private static final float GRAVITY = PHYSICS.gravity();
private static final float RUN_SPEED = PHYSICS.runSpeed();
private static final float JUMP_SPEED = PHYSICS.jumpSpeed();
private static final float COYOTE_SECONDS = PHYSICS.coyoteSeconds();
private static final float JUMP_BUFFER_SECONDS = PHYSICS.jumpBufferSeconds();
private static final float RESPAWN_SECONDS = PHYSICS.respawnSeconds();
private static final int MAX_CORPSES = PHYSICS.maximumBodies();
private enum Screen { LEVELS, SETTINGS, PLAYING, COMPLETE }
private enum DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT }
@@ -63,6 +64,8 @@ public final class GameView extends View implements Runnable {
private final boolean firstMovingPlatformMovingDown;
private final float firstCorpseX;
private final boolean playerOnGround;
private final float playerVelocityX;
private final float playerVelocityY;
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
@@ -113,6 +116,24 @@ public final class GameView extends View implements Runnable {
boolean firstMovingPlatformMovingRight,
boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
firstMovingPlatformPowered, firstMovingPlatformMovingRight,
firstMovingPlatformMovingDown, firstCorpseX, playerOnGround, 0f, 0f);
}
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
long elapsedMilliseconds,
float playerX, float playerY, int deaths, int corpseCount,
float firstMovingPlatformX, float firstMovingPlatformY,
boolean playerOnMovingPlatform,
boolean firstMovingPlatformPowered,
boolean firstMovingPlatformMovingRight,
boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround, float playerVelocityX,
float playerVelocityY) {
this.completed = completed;
this.playerAlive = playerAlive;
this.allDoorsOpen = allDoorsOpen;
@@ -132,6 +153,8 @@ public final class GameView extends View implements Runnable {
this.firstMovingPlatformMovingDown = firstMovingPlatformMovingDown;
this.firstCorpseX = firstCorpseX;
this.playerOnGround = playerOnGround;
this.playerVelocityX = playerVelocityX;
this.playerVelocityY = playerVelocityY;
}
public boolean completed() { return completed; }
@@ -153,6 +176,8 @@ public final class GameView extends View implements Runnable {
public boolean firstMovingPlatformMovingDown() { return firstMovingPlatformMovingDown; }
public float firstCorpseX() { return firstCorpseX; }
public boolean playerOnGround() { return playerOnGround; }
public float playerVelocityX() { return playerVelocityX; }
public float playerVelocityY() { return playerVelocityY; }
}
private static final class Player {
@@ -204,6 +229,8 @@ public final class GameView extends View implements Runnable {
private final OnboardingStore onboardingStore;
private final GameLanguageStore languageStore;
private final AccessibilitySettingsStore accessibilitySettingsStore;
private final AudioSettingsStore audioSettingsStore;
private final ProceduralAudioEngine audioEngine;
private final SparseArray<PointF> pointers = new SparseArray<>();
private final Player player = new Player();
private final ArrayList<Corpse> corpses = new ArrayList<>();
@@ -213,6 +240,10 @@ public final class GameView extends View implements Runnable {
private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
private final AccessibilityStateCache accessibilityStateCache =
new AccessibilityStateCache();
private final VolunteerCanvasRenderer volunteerRenderer = new VolunteerCanvasRenderer();
private final CameraController cameraController =
new CameraController(VIEW_WIDTH, VIEW_HEIGHT);
private final PerformanceMonitor performanceMonitor = new PerformanceMonitor(240);
private Screen screen = Screen.LEVELS;
private LevelDefinition level;
@@ -221,6 +252,9 @@ public final class GameView extends View implements Runnable {
private float levelElapsed;
private float respawnTimer;
private float cameraX;
private float cameraY;
private float cameraShakeX;
private float cameraShakeY;
private boolean leftPressed;
private boolean rightPressed;
private boolean jumpPressed;
@@ -237,6 +271,13 @@ public final class GameView extends View implements Runnable {
private GameLanguage language;
private GameStrings strings;
private AccessibilitySettings accessibilitySettings;
private AudioSettings audioSettings;
private float lastFootstepAt = -10f;
private boolean previousAllDoorsOpen;
private boolean deterministicAutomationClock;
private double deterministicAccumulatorSeconds;
private float lastDrawDurationMs;
private int performanceFrameCounter;
private String cachedLevelHeader = "";
private String cachedLevelHint = "";
private float cachedLevelHintSize = 25f;
@@ -260,6 +301,9 @@ public final class GameView extends View implements Runnable {
strings = GameStrings.forLanguage(language);
accessibilitySettingsStore = new AccessibilitySettingsStore(context);
accessibilitySettings = accessibilitySettingsStore.load();
audioSettingsStore = new AudioSettingsStore(context);
audioSettings = audioSettingsStore.load();
audioEngine = new ProceduralAudioEngine(audioSettings);
paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL));
stroke.setStyle(Paint.Style.STROKE);
stroke.setStrokeCap(Paint.Cap.ROUND);
@@ -273,17 +317,31 @@ public final class GameView extends View implements Runnable {
@Override
protected void onDetachedFromWindow() {
running = false;
audioEngine.release();
super.onDetachedFromWindow();
}
@Override
public void run() {
if (!running) return;
if (deterministicAutomationClock) {
postDelayed(this, 100L);
return;
}
long now = System.nanoTime();
if (lastFrameNanos == 0L) lastFrameNanos = now;
float dt = Math.min(1f / 30f, (now - lastFrameNanos) / 1_000_000_000f);
float frameIntervalMs = (now - lastFrameNanos) / 1_000_000f;
float dt = Math.min(1f / 30f, frameIntervalMs / 1_000f);
lastFrameNanos = now;
if (!paused && !userPaused && screen == Screen.PLAYING && dt > 0f) update(dt);
long updateStartedAt = System.nanoTime();
if (!deterministicAutomationClock && !paused && !userPaused
&& screen == Screen.PLAYING && dt > 0f) update(dt);
float updateDurationMs = (System.nanoTime() - updateStartedAt) / 1_000_000f;
if (!deterministicAutomationClock && screen == Screen.PLAYING && frameIntervalMs > 0f) {
performanceMonitor.recordFrame(frameIntervalMs, updateDurationMs,
lastDrawDurationMs);
if ((performanceFrameCounter++ % 30) == 0) recordPerformanceComplexity();
}
invalidate();
postOnAnimation(this);
}
@@ -291,6 +349,7 @@ public final class GameView extends View implements Runnable {
public void setPaused(boolean value) {
paused = value;
if (value && screen == Screen.PLAYING) userPaused = true;
if (value || userPaused) audioEngine.pause();
pointers.clear();
deriveControls();
lastFrameNanos = System.nanoTime();
@@ -312,6 +371,7 @@ public final class GameView extends View implements Runnable {
userPaused = false;
} else {
screen = Screen.LEVELS;
audioEngine.pause();
}
pointers.clear();
deriveControls();
@@ -327,6 +387,14 @@ public final class GameView extends View implements Runnable {
levelElapsed = 0f;
respawnTimer = 0f;
cameraX = 0f;
cameraY = 0f;
cameraShakeX = 0f;
cameraShakeY = 0f;
cameraController.reset();
lastFootstepAt = -10f;
previousAllDoorsOpen = false;
performanceMonitor.reset();
performanceFrameCounter = 0;
userPaused = false;
pointers.clear();
deriveControls();
@@ -359,10 +427,12 @@ public final class GameView extends View implements Runnable {
player.jumpBuffer = 0f;
player.supportIndex = -1;
player.facing = 1;
if (deaths > 0) audioEngine.play(AudioCue.RESPAWN_VOICE);
}
private void update(float dt) {
levelElapsed += dt;
audioEngine.updateMusic(levelElapsed, level.number);
updateSignals(dt);
updatePlatforms(dt);
@@ -381,11 +451,31 @@ public final class GameView extends View implements Runnable {
checkGoal();
}
if (player.alive && player.onGround && Math.abs(player.vx) >= 100f
&& levelElapsed - lastFootstepAt >= 0.25f) {
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();
float targetCamera = player.alive ? player.x - 610f + player.facing * 110f : cameraX;
targetCamera = GameMath.clamp(targetCamera, 0f, Math.max(0f, level.worldWidth - VIEW_WIDTH));
cameraX += (targetCamera - cameraX) * Math.min(1f, dt * 4.5f);
CameraController.Frame camera = cameraController.update(dt,
player.x, player.y, player.facing, level.worldWidth, VIEW_HEIGHT,
accessibilitySettings.reducedMotionEnabled());
cameraX = camera.x();
cameraY = camera.y();
cameraShakeX = camera.shakeX();
cameraShakeY = camera.shakeY();
}
private void updateSignals(float dt) {
@@ -405,10 +495,17 @@ public final class GameView extends View implements Runnable {
timedSignals.put(item.signalId, item.durationSeconds);
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
}
if (contact && !previous) audioEngine.play(AudioCue.SWITCH);
previousSwitchContact.put(contactKey, contact);
}
}
private boolean allDoorsOpenNow() {
if (level == null || level.doors.isEmpty()) return false;
for (LevelDefinition.Door door : level.doors) if (!doorOpen(door)) return false;
return true;
}
private boolean switchContact(LevelDefinition.Switch item) {
int mass = 0;
if (player.alive && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) mass++;
@@ -489,6 +586,7 @@ public final class GameView extends View implements Runnable {
player.coyote = 0f;
player.jumpBuffer = 0f;
emitHaptic(HapticFeedbackConstants.CLOCK_TICK);
audioEngine.play(AudioCue.JUMP);
}
player.vy += GRAVITY * dt;
@@ -663,6 +761,9 @@ public final class GameView extends View implements Runnable {
respawnTimer = RESPAWN_SECONDS;
emitHaptic(Build.VERSION.SDK_INT >= 27
? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP);
audioEngine.play(AudioCue.DEATH_IMPACT);
audioEngine.play(AudioCue.DEATH_VOICE);
cameraController.impact(type == DeathType.PIT ? 12f : 24f);
if (type != DeathType.PIT) {
Corpse corpse = new Corpse();
@@ -716,9 +817,11 @@ public final class GameView extends View implements Runnable {
);
player.vx = player.vy = 0f;
screen = Screen.COMPLETE;
audioEngine.pause();
pointers.clear();
deriveControls();
emitHaptic(HapticFeedbackConstants.LONG_PRESS);
audioEngine.play(AudioCue.GOAL_VOICE);
refreshAccessibilityDescription(true);
}
@@ -736,6 +839,7 @@ public final class GameView extends View implements Runnable {
@Override
protected void onDraw(Canvas canvas) {
long drawStartedAt = System.nanoTime();
super.onDraw(canvas);
updateViewport(getWidth(), getHeight());
@@ -757,12 +861,29 @@ public final class GameView extends View implements Runnable {
else if (onboardingFlow.isVisible()) drawOnboarding(canvas);
}
canvas.restore();
lastDrawDurationMs = (System.nanoTime() - drawStartedAt) / 1_000_000f;
}
private void recordPerformanceComplexity() {
if (level == null) return;
int bodies = corpses.size();
int solids = platformStates.size() + level.doors.size() + bodies;
int actors = 1 + bodies;
int collisionChecks = solids * actors * 2
+ level.hazards.size() + level.switches.size() * actors;
int drawPrimitives = 180 + platformStates.size() * 8
+ level.switches.size() * 9 + level.hazards.size() * 20
+ level.doors.size() * 12 + bodies * 28;
Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
performanceMonitor.recordComplexity(bodies, collisionChecks,
drawPrimitives, usedMemory);
}
private void drawGame(Canvas canvas) {
drawSky(canvas);
canvas.save();
canvas.translate(-cameraX, 0f);
canvas.translate(-cameraX + cameraShakeX, -cameraY + cameraShakeY);
drawWorldBackdrop(canvas);
drawPlatforms(canvas);
drawSwitches(canvas);
@@ -770,11 +891,30 @@ public final class GameView extends View implements Runnable {
drawDoors(canvas);
drawGoal(canvas);
for (Corpse corpse : corpses) drawCorpse(canvas, corpse);
if (player.alive) drawKnight(canvas, player.x, player.y, player.facing, false);
VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number);
if (player.alive) {
VolunteerRig.Motion motion = VolunteerRig.aliveMotion(
player.onGround, player.vx, player.vy);
volunteerRenderer.draw(canvas, paint, stroke, path,
player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT,
skin, motion, volunteerPhase(motion), player.facing, 1f,
accessibilitySettings.highContrastEnabled());
} else if (respawnTimer > 0f) {
float progress = 1f - GameMath.clamp(respawnTimer / RESPAWN_SECONDS, 0f, 1f);
volunteerRenderer.draw(canvas, paint, stroke, path,
level.spawnX, level.spawnY, PLAYER_WIDTH, PLAYER_HEIGHT,
skin, VolunteerRig.Motion.RESPAWN, progress, 1, 0.72f,
accessibilitySettings.highContrastEnabled());
}
canvas.restore();
drawHud(canvas);
}
private float volunteerPhase(VolunteerRig.Motion motion) {
if (motion == VolunteerRig.Motion.RUN) return levelElapsed * 2.6f;
return levelElapsed * 0.65f;
}
private void drawSky(Canvas canvas) {
if (accessibilitySettings.highContrastEnabled()) {
canvas.drawColor(Color.BLACK);
@@ -920,6 +1060,7 @@ public final class GameView extends View implements Runnable {
}
}
private void drawSaw(Canvas canvas, RectF box) {
float angle = levelElapsed * 4.5f;
canvas.save();
@@ -962,44 +1103,30 @@ public final class GameView extends View implements Runnable {
private void drawGoal(Canvas canvas) {
RectF box = rect(level.goal);
paint.setColor(Color.argb(48, 252, 211, 94));
canvas.drawCircle(box.centerX(), box.centerY(), 92f + (float) Math.sin(levelElapsed * 3f) * 9f, paint);
paint.setColor(Color.rgb(246, 197, 75));
path.reset();
path.moveTo(box.left + 10, box.top + 35);
path.lineTo(box.left + 24, box.top + 5);
path.lineTo(box.centerX(), box.top + 32);
path.lineTo(box.right - 22, box.top + 5);
path.lineTo(box.right - 8, box.top + 35);
path.lineTo(box.right - 20, box.bottom - 8);
path.lineTo(box.left + 20, box.bottom - 8);
path.close();
canvas.drawPath(path, paint);
paint.setColor(Color.rgb(245, 235, 204));
canvas.drawCircle(box.centerX(), box.centerY() + 16, 12, paint);
}
private void drawKnight(Canvas canvas, float x, float y, int facing, boolean corpse) {
float alpha = corpse ? 0.78f : 1f;
paint.setColor(withAlpha(Color.rgb(226, 230, 235), alpha));
canvas.drawRoundRect(new RectF(x + 10, y + 25, x + 52, y + 73), 16, 16, paint);
paint.setColor(withAlpha(Color.rgb(95, 105, 123), alpha));
canvas.drawRoundRect(new RectF(x + 6, y + 46, x + 56, y + 91), 12, 12, paint);
paint.setColor(withAlpha(Color.rgb(177, 62, 69), alpha));
path.reset();
path.moveTo(x + (facing > 0 ? 14 : 48), y + 50);
path.lineTo(x + (facing > 0 ? -8 : 70), y + 92);
path.lineTo(x + 31, y + 80);
path.close();
canvas.drawPath(path, paint);
paint.setColor(withAlpha(Color.rgb(36, 40, 52), alpha));
canvas.drawRect(x + 14, y + 47, x + 48, y + 54, paint);
paint.setColor(withAlpha(Color.rgb(243, 196, 80), alpha));
float eyeX = facing > 0 ? x + 42 : x + 20;
canvas.drawCircle(eyeX, y + 50, 4, paint);
paint.setColor(withAlpha(Color.rgb(64, 72, 91), alpha));
canvas.drawRoundRect(new RectF(x + 10, y + 86, x + 27, y + 100), 5, 5, paint);
canvas.drawRoundRect(new RectF(x + 35, y + 86, x + 52, y + 100), 5, 5, paint);
float pulse = accessibilitySettings.highContrastEnabled()
? 0f : (float) Math.sin(levelElapsed * 3f) * 8f;
paint.setColor(Color.argb(52, 99, 239, 215));
canvas.drawCircle(box.centerX(), box.centerY(), 88f + pulse, paint);
stroke.setStrokeWidth(6f);
stroke.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : Color.rgb(224, 176, 73));
canvas.drawLine(box.centerX(), box.top - 62f,
box.centerX(), box.centerY() - 38f, stroke);
canvas.save();
canvas.rotate(accessibilitySettings.highContrastEnabled()
? 0f : levelElapsed * 24f, box.centerX(), box.centerY());
canvas.drawOval(box.left + 4f, box.centerY() - 26f,
box.right - 4f, box.centerY() + 26f, stroke);
canvas.rotate(62f, box.centerX(), box.centerY());
canvas.drawOval(box.left + 12f, box.centerY() - 18f,
box.right - 12f, box.centerY() + 18f, stroke);
canvas.restore();
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.rgb(255, 214, 77) : Color.rgb(93, 232, 214));
canvas.drawCircle(box.centerX(), box.centerY(), 24f + pulse * 0.15f, paint);
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.BLACK : Color.rgb(239, 252, 225));
canvas.drawCircle(box.centerX() - 6f, box.centerY() - 7f, 7f, paint);
}
private void drawCorpse(Canvas canvas, Corpse corpse) {
@@ -1007,23 +1134,16 @@ public final class GameView extends View implements Runnable {
float cx = corpse.x + corpse.width * 0.5f;
float cy = corpse.y + corpse.height * 0.5f;
if (!corpse.frozen) canvas.rotate(90, cx, cy);
if (corpse.frozen) {
paint.setColor(Color.argb(190, 94, 219, 247));
canvas.drawRoundRect(corpse.rect(), 15, 15, paint);
stroke.setColor(Color.rgb(219, 251, 255));
stroke.setStrokeWidth(5f);
canvas.drawRoundRect(new RectF(corpse.x + 7, corpse.y + 7, corpse.x + corpse.width - 7, corpse.y + corpse.height - 7), 10, 10, stroke);
drawKnight(canvas, corpse.x + 20, corpse.y + 2, 1, true);
} else {
float drawX = cx - PLAYER_WIDTH * 0.5f;
float drawY = cy - PLAYER_HEIGHT * 0.5f;
drawKnight(canvas, drawX, drawY, 1, true);
}
float drawX = cx - PLAYER_WIDTH * 0.5f;
float drawY = cy - PLAYER_HEIGHT * 0.5f;
VolunteerRig.Motion motion = VolunteerRig.bodyMotion(
corpse.frozen, corpse.conductive, corpse.pinned);
volunteerRenderer.draw(canvas, paint, stroke, path,
drawX, drawY, PLAYER_WIDTH, PLAYER_HEIGHT,
VolunteerRig.Skin.forLevel(level.number), motion,
levelElapsed * 1.6f, 1, 0.82f,
accessibilitySettings.highContrastEnabled());
canvas.restore();
if (corpse.conductive) {
paint.setColor(Color.argb(120, 249, 239, 94));
canvas.drawCircle(cx, cy, 54 + (float) Math.sin(levelElapsed * 8) * 6, paint);
}
}
private void drawHud(Canvas canvas) {
@@ -1152,6 +1272,20 @@ public final class GameView extends View implements Runnable {
drawCompactSettingRow(canvas, new RectF(55, 450, 385, 580),
strings.highContrastSetting(),
strings.highContrastValue(accessibilitySettings.highContrastEnabled()));
drawCompactSettingRow(canvas, new RectF(55, 620, 385, 750),
strings.reducedMotionSetting(),
strings.reducedMotionValue(accessibilitySettings.reducedMotionEnabled()));
drawCenteredText(canvas, strings.audioTitle(), 1_700f, 240, 25,
Color.rgb(248, 212, 126));
drawCompactSettingRow(canvas, new RectF(1_535, 280, 1_865, 410),
strings.musicSetting(),
strings.volumeValue(audioSettings, audioSettings.musicPreset()));
drawCompactSettingRow(canvas, new RectF(1_535, 450, 1_865, 580),
strings.effectsSetting(),
strings.volumeValue(audioSettings, audioSettings.effectsPreset()));
drawCompactSettingRow(canvas, new RectF(1_535, 620, 1_865, 750),
strings.voiceSetting(),
strings.volumeValue(audioSettings, audioSettings.voicePreset()));
drawHudButton(canvas, new RectF(710, 850, 1210, 960), strings.back(), true);
}
@@ -1431,6 +1565,7 @@ public final class GameView extends View implements Runnable {
}
if (contains(1790, 26, 1892, 112, point.x, point.y)) {
userPaused = true;
audioEngine.pause();
pointers.clear();
deriveControls();
refreshAccessibilityDescription(true);
@@ -1524,7 +1659,6 @@ public final class GameView extends View implements Runnable {
private void advanceOnboarding() {
onboardingFlow.next();
if (onboardingFlow.isComplete()) onboardingStore.markComplete();
invalidate();
}
private void levelsFromResult() {
@@ -1550,6 +1684,7 @@ public final class GameView extends View implements Runnable {
else if (contains(610, 650, 1310, 775, x, y)) {
userPaused = false;
screen = Screen.LEVELS;
audioEngine.pause();
}
refreshAccessibilityDescription(true);
}
@@ -1561,6 +1696,9 @@ public final class GameView extends View implements Runnable {
} else if (contains(55, 450, 385, 580, x, y)) {
updateAccessibilitySettings(accessibilitySettings.toggleHighContrast());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(55, 620, 385, 750, x, y)) {
updateAccessibilitySettings(accessibilitySettings.toggleReducedMotion());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(1580, 70, 1850, 160, x, y)) {
updateLanguage(language.next());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
@@ -1573,8 +1711,18 @@ public final class GameView extends View implements Runnable {
} else if (contains(420, 620, 1500, 750, x, y)) {
updateControlSettings(controlSettings.toggleHaptics());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(1535, 280, 1865, 410, x, y)) {
updateAudioSettings(audioSettings.nextMusicVolume());
audioEngine.play(AudioCue.UI_TAP);
} else if (contains(1535, 450, 1865, 580, x, y)) {
updateAudioSettings(audioSettings.nextEffectsVolume());
audioEngine.play(AudioCue.UI_TAP);
} else if (contains(1535, 620, 1865, 750, x, y)) {
updateAudioSettings(audioSettings.nextVoiceVolume());
audioEngine.play(AudioCue.UI_TAP);
} else if (contains(710, 850, 1210, 960, x, y)) {
screen = Screen.LEVELS;
audioEngine.play(AudioCue.UI_TAP);
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
refreshAccessibilityDescription(true);
}
@@ -1611,6 +1759,42 @@ public final class GameView extends View implements Runnable {
startLevel(oneBasedLevelNumber - 1);
}
public void enableDeterministicAutomationClock() {
deterministicAutomationClock = true;
deterministicAccumulatorSeconds = 0d;
audioEngine.setSynthesisEnabled(false);
lastFrameNanos = System.nanoTime();
}
public void disableDeterministicAutomationClock() {
deterministicAutomationClock = false;
deterministicAccumulatorSeconds = 0d;
audioEngine.setSynthesisEnabled(true);
lastFrameNanos = System.nanoTime();
}
public void advanceDeterministicAutomation(long milliseconds) {
if (!deterministicAutomationClock) {
throw new IllegalStateException("Deterministic automation clock is disabled");
}
if (milliseconds < 0L || milliseconds > 120_000L) {
throw new IllegalArgumentException("Invalid deterministic advance: " + milliseconds);
}
deterministicAccumulatorSeconds += milliseconds / 1_000d;
double fixedStep = PHYSICS.fixedStepSeconds();
while (deterministicAccumulatorSeconds + 1e-9d >= fixedStep) {
if (!userPaused && screen == Screen.PLAYING) update((float) fixedStep);
deterministicAccumulatorSeconds -= fixedStep;
}
}
public String deterministicStateSignatureForAutomation() {
GameSessionSnapshot snapshot = captureActiveSession();
if (snapshot == null) throw new IllegalStateException("No puzzle state to sign");
return ReplayStateSignature.of(VersionedSessionCodec.encode(snapshot),
screen == Screen.COMPLETE, allDoorsOpenNow());
}
public void restartLevelForAutomation() {
restartLevel();
}
@@ -1749,6 +1933,15 @@ public final class GameView extends View implements Runnable {
visible.add(strings.textSizeValue(accessibilitySettings.textScale()));
visible.add(strings.highContrastSetting());
visible.add(strings.highContrastValue(accessibilitySettings.highContrastEnabled()));
visible.add(strings.reducedMotionSetting());
visible.add(strings.reducedMotionValue(accessibilitySettings.reducedMotionEnabled()));
visible.add(strings.audioTitle());
visible.add(strings.musicSetting());
visible.add(strings.volumeValue(audioSettings, audioSettings.musicPreset()));
visible.add(strings.effectsSetting());
visible.add(strings.volumeValue(audioSettings, audioSettings.effectsPreset()));
visible.add(strings.voiceSetting());
visible.add(strings.volumeValue(audioSettings, audioSettings.voicePreset()));
visible.add(strings.back());
} else if (level != null) {
visible.add(String.format(Locale.US, "%02d %s", level.number,
@@ -1800,6 +1993,34 @@ public final class GameView extends View implements Runnable {
return accessibilitySettings;
}
public AudioSettings audioSettingsForAutomation() {
return audioSettings;
}
public void resetAudioSettingsForAutomation() {
updateAudioSettings(AudioSettings.defaults());
}
public void selectNextMusicVolumeForAutomation() {
updateAudioSettings(audioSettings.nextMusicVolume());
}
public void selectNextEffectsVolumeForAutomation() {
updateAudioSettings(audioSettings.nextEffectsVolume());
}
public void selectNextVoiceVolumeForAutomation() {
updateAudioSettings(audioSettings.nextVoiceVolume());
}
public AudioCue lastAudioCueForAutomation() {
return audioEngine.lastCue();
}
public int audioCueCountForAutomation() {
return audioEngine.cueCount();
}
public void resetAccessibilitySettingsForAutomation() {
updateAccessibilitySettings(AccessibilitySettings.defaults());
}
@@ -1812,6 +2033,10 @@ public final class GameView extends View implements Runnable {
updateAccessibilitySettings(accessibilitySettings.toggleHighContrast());
}
public void toggleReducedMotionForAutomation() {
updateAccessibilitySettings(accessibilitySettings.toggleReducedMotion());
}
public float textScaleForAutomation() {
return accessibilitySettings.textScale();
}
@@ -1820,6 +2045,26 @@ public final class GameView extends View implements Runnable {
return accessibilitySettings.highContrastEnabled();
}
public boolean reducedMotionForAutomation() {
return accessibilitySettings.reducedMotionEnabled();
}
public float[] cameraStateForAutomation() {
CameraController.Frame camera = cameraController.frame();
return new float[] {camera.x(), camera.y(), camera.shakeX(), camera.shakeY()};
}
public PerformanceMonitor.Snapshot performanceSnapshotForAutomation() {
recordPerformanceComplexity();
return performanceMonitor.snapshot();
}
public void resetPerformanceMetricsForAutomation() {
performanceMonitor.reset();
performanceFrameCounter = 0;
lastFrameNanos = System.nanoTime();
}
public String screenDescriptionForAutomation() {
return currentAccessibilityDescription();
}
@@ -1836,6 +2081,7 @@ public final class GameView extends View implements Runnable {
public void pauseForAutomation() {
if (screen == Screen.PLAYING) {
userPaused = true;
audioEngine.pause();
pointers.clear();
deriveControls();
refreshAccessibilityDescription(false);
@@ -1845,6 +2091,7 @@ public final class GameView extends View implements Runnable {
public void showCompleteForAutomation() {
if (level != null) {
screen = Screen.COMPLETE;
audioEngine.pause();
pointers.clear();
deriveControls();
refreshAccessibilityDescription(false);
@@ -1878,6 +2125,14 @@ public final class GameView extends View implements Runnable {
invalidate();
}
private void updateAudioSettings(AudioSettings updated) {
audioSettings = updated == null ? AudioSettings.defaults() : updated;
audioSettingsStore.save(audioSettings);
audioEngine.updateSettings(audioSettings);
refreshAccessibilityDescription(false);
invalidate();
}
private void invalidateHudTextCache() {
cachedHudLevelNumber = -1;
cachedHudLanguage = null;
@@ -1952,7 +2207,7 @@ public final class GameView extends View implements Runnable {
}
if (screen == Screen.SETTINGS) {
return AccessibilityScreenDescription.settings(strings, controlSettings,
accessibilitySettings);
accessibilitySettings, audioSettings);
}
if (screen == Screen.COMPLETE) {
return AccessibilityScreenDescription.complete(strings, level, deaths,
@@ -1988,6 +2243,142 @@ public final class GameView extends View implements Runnable {
if (jump && !jumpWasPressed && player.alive) player.jumpBuffer = JUMP_BUFFER_SECONDS;
}
public GameSessionSnapshot sessionSnapshotForAutomation() {
return captureActiveSession();
}
byte[] saveSessionForActivityState() {
if (screen != Screen.PLAYING) return null;
GameSessionSnapshot snapshot = captureActiveSession();
return snapshot == null ? null : VersionedSessionCodec.encode(snapshot);
}
boolean restoreSessionFromActivityState(byte[] encoded) {
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(encoded);
if (decoded.snapshot() == null) return false;
return restoreActiveSession(decoded.snapshot());
}
private GameSessionSnapshot captureActiveSession() {
if ((screen != Screen.PLAYING && screen != Screen.COMPLETE) || level == null) return null;
GameSessionSnapshot.Builder builder = GameSessionSnapshot.builder(level.number)
.elapsedSeconds(levelElapsed)
.deaths(deaths)
.respawnSeconds(Math.max(0f, respawnTimer))
.player(new GameSessionSnapshot.PlayerData(
player.x, player.y, player.vx, player.vy,
player.alive, player.onGround, player.coyote,
player.jumpBuffer, player.supportIndex, player.facing));
for (Corpse corpse : corpses) {
builder.addBody(new GameSessionSnapshot.BodyData(
corpse.x, corpse.y, corpse.width, corpse.height,
corpse.vx, corpse.vy, corpse.frozen, corpse.conductive,
corpse.pinned, corpse.grounded, corpse.supportIndex,
corpse.type == null ? DeathType.SPIKE.name() : corpse.type.name()));
}
ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size());
for (PlatformState state : platformStates) motionTimes.add(state.motionTime);
builder.platformMotionTimes(motionTimes);
for (Map.Entry<String, Boolean> entry : toggleSignals.entrySet()) {
builder.toggleSignal(entry.getKey(), Boolean.TRUE.equals(entry.getValue()));
}
for (Map.Entry<String, Float> entry : timedSignals.entrySet()) {
builder.timedSignal(entry.getKey(), Math.max(0f, entry.getValue()));
}
for (Map.Entry<String, Boolean> entry : previousSwitchContact.entrySet()) {
builder.previousSwitchContact(entry.getKey(), Boolean.TRUE.equals(entry.getValue()));
}
return builder.build();
}
private boolean restoreActiveSession(GameSessionSnapshot snapshot) {
int index = snapshot.levelNumber() - 1;
if (index < 0 || index >= LevelCatalog.all().size()) return false;
LevelDefinition definition = LevelCatalog.get(index);
if (snapshot.platformMotionTimes().size() != definition.platforms.size()
|| snapshot.bodies().size() > MAX_CORPSES) return false;
if (!validSupport(snapshot.player().supportIndex(), definition.platforms.size())) {
return false;
}
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) {
if (!validSupport(body.supportIndex(), definition.platforms.size())) return false;
try {
DeathType.valueOf(body.deathType());
} catch (IllegalArgumentException unsupportedType) {
return false;
}
}
startLevel(index);
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());
for (int i = 0; i < platformStates.size(); i++) {
PlatformState state = platformStates.get(i);
state.motionTime = snapshot.platformMotionTimes().get(i);
float phase = state.definition.isMoving()
? GameMath.pingPong(state.motionTime, state.definition.periodSeconds) : 0f;
state.x = state.previousX = state.definition.box.x + state.definition.travelX * phase;
state.y = state.previousY = state.definition.box.y + state.definition.travelY * phase;
}
GameSessionSnapshot.PlayerData savedPlayer = snapshot.player();
player.x = savedPlayer.x();
player.y = savedPlayer.y();
player.vx = savedPlayer.velocityX();
player.vy = savedPlayer.velocityY();
player.alive = savedPlayer.alive();
player.onGround = savedPlayer.onGround();
player.coyote = savedPlayer.coyoteSeconds();
player.jumpBuffer = savedPlayer.jumpBufferSeconds();
player.supportIndex = savedPlayer.supportIndex();
player.facing = savedPlayer.facing();
corpses.clear();
for (GameSessionSnapshot.BodyData savedBody : snapshot.bodies()) {
Corpse corpse = new Corpse();
corpse.x = savedBody.x();
corpse.y = savedBody.y();
corpse.width = savedBody.width();
corpse.height = savedBody.height();
corpse.vx = savedBody.velocityX();
corpse.vy = savedBody.velocityY();
corpse.frozen = savedBody.frozen();
corpse.conductive = savedBody.conductive();
corpse.pinned = savedBody.pinned();
corpse.grounded = savedBody.grounded();
corpse.supportIndex = savedBody.supportIndex();
corpse.type = DeathType.valueOf(savedBody.deathType());
corpses.add(corpse);
}
leftPressed = false;
rightPressed = false;
jumpPressed = false;
pointers.clear();
userPaused = true;
paused = false;
lastFootstepAt = levelElapsed;
previousAllDoorsOpen = allDoorsOpenNow();
cameraController.reset();
cameraX = cameraY = cameraShakeX = cameraShakeY = 0f;
lastFrameNanos = System.nanoTime();
invalidateHudTextCache();
refreshAccessibilityDescription(true);
invalidate();
return true;
}
private static boolean validSupport(int supportIndex, int platformCount) {
return supportIndex >= -1 && supportIndex < platformCount;
}
public AutomationSnapshot automationSnapshot() {
boolean allOpen = level != null && !level.doors.isEmpty();
if (level != null) {
@@ -2031,7 +2422,9 @@ public final class GameView extends View implements Runnable {
firstMovingPlatformMovingRight,
firstMovingPlatformMovingDown,
corpses.isEmpty() ? Float.NaN : corpses.get(0).x,
player.onGround
player.onGround,
player.vx,
player.vy
);
}
}
@@ -9,6 +9,7 @@ import android.view.WindowInsetsController;
import android.view.WindowManager;
public final class MainActivity extends Activity {
private static final String ACTIVE_SESSION_STATE = "active_session_v2";
private GameView gameView;
@Override
@@ -17,10 +18,23 @@ public final class MainActivity extends Activity {
requestWindowFeature(Window.FEATURE_NO_TITLE);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
gameView = new GameView(this);
if (savedInstanceState != null) {
byte[] encoded = savedInstanceState.getByteArray(ACTIVE_SESSION_STATE);
if (encoded != null) gameView.restoreSessionFromActivityState(encoded);
}
setContentView(gameView);
hideSystemUi();
}
@Override
protected void onSaveInstanceState(Bundle outState) {
if (gameView != null) {
byte[] encoded = gameView.saveSessionForActivityState();
if (encoded != null) outState.putByteArray(ACTIVE_SESSION_STATE, encoded);
}
super.onSaveInstanceState(outState);
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
@@ -0,0 +1,40 @@
package com.projectsacrifice.game;
/** Published vertical-slice runtime ceilings for the supported 10-level build. */
public final class PerformanceBudget {
private static final PerformanceBudget RELEASE = new PerformanceBudget(
60, 34f, 12f, 20f, 16, 2_500, 2_200, 128L * 1024L * 1024L);
private final int targetFramesPerSecond;
private final float maximumP95FrameIntervalMs;
private final float maximumP95UpdateMs;
private final float maximumP95DrawMs;
private final int maximumBodies;
private final int maximumCollisionChecks;
private final int maximumDrawPrimitives;
private final long maximumUsedMemoryBytes;
private PerformanceBudget(int targetFramesPerSecond, float maximumP95FrameIntervalMs,
float maximumP95UpdateMs, float maximumP95DrawMs,
int maximumBodies, int maximumCollisionChecks,
int maximumDrawPrimitives, long maximumUsedMemoryBytes) {
this.targetFramesPerSecond = targetFramesPerSecond;
this.maximumP95FrameIntervalMs = maximumP95FrameIntervalMs;
this.maximumP95UpdateMs = maximumP95UpdateMs;
this.maximumP95DrawMs = maximumP95DrawMs;
this.maximumBodies = maximumBodies;
this.maximumCollisionChecks = maximumCollisionChecks;
this.maximumDrawPrimitives = maximumDrawPrimitives;
this.maximumUsedMemoryBytes = maximumUsedMemoryBytes;
}
public static PerformanceBudget release() { return RELEASE; }
public int targetFramesPerSecond() { return targetFramesPerSecond; }
public float maximumP95FrameIntervalMs() { return maximumP95FrameIntervalMs; }
public float maximumP95UpdateMs() { return maximumP95UpdateMs; }
public float maximumP95DrawMs() { return maximumP95DrawMs; }
public int maximumBodies() { return maximumBodies; }
public int maximumCollisionChecks() { return maximumCollisionChecks; }
public int maximumDrawPrimitives() { return maximumDrawPrimitives; }
public long maximumUsedMemoryBytes() { return maximumUsedMemoryBytes; }
}
@@ -0,0 +1,150 @@
package com.projectsacrifice.game;
import java.util.Arrays;
import java.util.Locale;
/** Allocation-free rolling samples; allocations occur only when a diagnostic snapshot is asked. */
public final class PerformanceMonitor {
public static final class Snapshot {
private final int sampleCount;
private final float p95FrameIntervalMs;
private final float p95UpdateMs;
private final float p95DrawMs;
private final int maximumBodies;
private final int maximumCollisionChecks;
private final int maximumDrawPrimitives;
private final long maximumUsedMemoryBytes;
private Snapshot(int sampleCount, float p95FrameIntervalMs, float p95UpdateMs,
float p95DrawMs, int maximumBodies, int maximumCollisionChecks,
int maximumDrawPrimitives, long maximumUsedMemoryBytes) {
this.sampleCount = sampleCount;
this.p95FrameIntervalMs = p95FrameIntervalMs;
this.p95UpdateMs = p95UpdateMs;
this.p95DrawMs = p95DrawMs;
this.maximumBodies = maximumBodies;
this.maximumCollisionChecks = maximumCollisionChecks;
this.maximumDrawPrimitives = maximumDrawPrimitives;
this.maximumUsedMemoryBytes = maximumUsedMemoryBytes;
}
public int sampleCount() { return sampleCount; }
public float p95FrameIntervalMs() { return p95FrameIntervalMs; }
public float p95UpdateMs() { return p95UpdateMs; }
public float p95DrawMs() { return p95DrawMs; }
public int maximumBodies() { return maximumBodies; }
public int maximumCollisionChecks() { return maximumCollisionChecks; }
public int maximumDrawPrimitives() { return maximumDrawPrimitives; }
public long maximumUsedMemoryBytes() { return maximumUsedMemoryBytes; }
public boolean within(PerformanceBudget budget) {
return p95FrameIntervalMs <= budget.maximumP95FrameIntervalMs()
&& p95UpdateMs <= budget.maximumP95UpdateMs()
&& p95DrawMs <= budget.maximumP95DrawMs()
&& maximumBodies <= budget.maximumBodies()
&& maximumCollisionChecks <= budget.maximumCollisionChecks()
&& maximumDrawPrimitives <= budget.maximumDrawPrimitives()
&& maximumUsedMemoryBytes <= budget.maximumUsedMemoryBytes();
}
public String failureSummary(PerformanceBudget budget) {
StringBuilder result = new StringBuilder();
append(result, "frameP95", p95FrameIntervalMs,
budget.maximumP95FrameIntervalMs());
append(result, "updateP95", p95UpdateMs, budget.maximumP95UpdateMs());
append(result, "drawP95", p95DrawMs, budget.maximumP95DrawMs());
append(result, "bodies", maximumBodies, budget.maximumBodies());
append(result, "collisionChecks", maximumCollisionChecks,
budget.maximumCollisionChecks());
append(result, "drawPrimitives", maximumDrawPrimitives,
budget.maximumDrawPrimitives());
append(result, "usedMemory", maximumUsedMemoryBytes,
budget.maximumUsedMemoryBytes());
return result.length() == 0 ? "within budget" : result.toString();
}
private static void append(StringBuilder result, String name, float actual,
float limit) {
if (actual <= limit) return;
if (result.length() > 0) result.append("; ");
result.append(name).append('=')
.append(String.format(Locale.US, "%.2f", actual))
.append('>').append(String.format(Locale.US, "%.2f", limit));
}
private static void append(StringBuilder result, String name, long actual,
long limit) {
if (actual <= limit) return;
if (result.length() > 0) result.append("; ");
result.append(name).append('=').append(actual).append('>').append(limit);
}
}
private final float[] frameIntervals;
private final float[] updateTimes;
private final float[] drawTimes;
private int nextIndex;
private int sampleCount;
private int maximumBodies;
private int maximumCollisionChecks;
private int maximumDrawPrimitives;
private long maximumUsedMemoryBytes;
public PerformanceMonitor(int windowSize) {
if (windowSize < 20) throw new IllegalArgumentException("Window must contain 20+ frames");
frameIntervals = new float[windowSize];
updateTimes = new float[windowSize];
drawTimes = new float[windowSize];
}
public void recordFrame(float intervalMs, float updateMs, float drawMs) {
frameIntervals[nextIndex] = finiteNonNegative(intervalMs);
updateTimes[nextIndex] = finiteNonNegative(updateMs);
drawTimes[nextIndex] = finiteNonNegative(drawMs);
nextIndex = (nextIndex + 1) % frameIntervals.length;
sampleCount = Math.min(frameIntervals.length, sampleCount + 1);
}
public void reset() {
Arrays.fill(frameIntervals, 0f);
Arrays.fill(updateTimes, 0f);
Arrays.fill(drawTimes, 0f);
nextIndex = 0;
sampleCount = 0;
maximumBodies = 0;
maximumCollisionChecks = 0;
maximumDrawPrimitives = 0;
maximumUsedMemoryBytes = 0L;
}
public void recordComplexity(int bodies, int collisionChecks,
int drawPrimitives, long usedMemoryBytes) {
maximumBodies = Math.max(maximumBodies, Math.max(0, bodies));
maximumCollisionChecks = Math.max(maximumCollisionChecks,
Math.max(0, collisionChecks));
maximumDrawPrimitives = Math.max(maximumDrawPrimitives, Math.max(0, drawPrimitives));
maximumUsedMemoryBytes = Math.max(maximumUsedMemoryBytes, Math.max(0L, usedMemoryBytes));
}
public Snapshot snapshot() {
return new Snapshot(sampleCount, percentile(frameIntervals, sampleCount, 0.95f),
percentile(updateTimes, sampleCount, 0.95f),
percentile(drawTimes, sampleCount, 0.95f), maximumBodies,
maximumCollisionChecks, maximumDrawPrimitives, maximumUsedMemoryBytes);
}
private static float percentile(float[] source, int count, float fraction) {
if (count <= 0) return 0f;
float[] values = Arrays.copyOf(source, count);
Arrays.sort(values);
int index = Math.min(count - 1, Math.max(0, (int) Math.ceil(count * fraction) - 1));
return values[index];
}
private static float finiteNonNegative(float value) {
if (Float.isNaN(value) || Float.isInfinite(value) || value < 0f) {
throw new IllegalArgumentException("Performance sample must be finite and non-negative");
}
return value;
}
}
@@ -0,0 +1,44 @@
package com.projectsacrifice.game;
/** Immutable release-tuned physics constants shared by realtime and deterministic automation. */
public final class PhysicsConfig {
private static final PhysicsConfig RELEASE = new PhysicsConfig(
1, 1f / 60f, 1_800f, 370f, 880f,
0.13f, 0.14f, 0.78f, 16);
private final int version;
private final float fixedStepSeconds;
private final float gravity;
private final float runSpeed;
private final float jumpSpeed;
private final float coyoteSeconds;
private final float jumpBufferSeconds;
private final float respawnSeconds;
private final int maximumBodies;
private PhysicsConfig(int version, float fixedStepSeconds, float gravity,
float runSpeed, float jumpSpeed, float coyoteSeconds,
float jumpBufferSeconds, float respawnSeconds,
int maximumBodies) {
this.version = version;
this.fixedStepSeconds = fixedStepSeconds;
this.gravity = gravity;
this.runSpeed = runSpeed;
this.jumpSpeed = jumpSpeed;
this.coyoteSeconds = coyoteSeconds;
this.jumpBufferSeconds = jumpBufferSeconds;
this.respawnSeconds = respawnSeconds;
this.maximumBodies = maximumBodies;
}
public static PhysicsConfig release() { return RELEASE; }
public int version() { return version; }
public float fixedStepSeconds() { return fixedStepSeconds; }
public float gravity() { return gravity; }
public float runSpeed() { return runSpeed; }
public float jumpSpeed() { return jumpSpeed; }
public float coyoteSeconds() { return coyoteSeconds; }
public float jumpBufferSeconds() { return jumpBufferSeconds; }
public float respawnSeconds() { return respawnSeconds; }
public int maximumBodies() { return maximumBodies; }
}
@@ -0,0 +1,164 @@
package com.projectsacrifice.game;
import android.media.AudioManager;
import android.media.ToneGenerator;
/**
* Tiny asset-free clockwork score and cue renderer. The semantic cue contract remains testable
* without audio hardware; ToneGenerator failures degrade to silence instead of gameplay failure.
*/
final class ProceduralAudioEngine {
private final AudioCueSequencer sequencer = new AudioCueSequencer(0x51ac71f5L);
private ToneGenerator music;
private ToneGenerator effects;
private ToneGenerator voice;
private AudioSettings settings;
private long lastMusicBeat = Long.MIN_VALUE;
private AudioCue lastCue;
private int cueCount;
private boolean synthesisEnabled = true;
ProceduralAudioEngine(AudioSettings settings) {
updateSettings(settings);
}
void updateSettings(AudioSettings updated) {
settings = updated == null ? AudioSettings.defaults() : updated;
releaseGenerators();
lastMusicBeat = Long.MIN_VALUE;
}
void play(AudioCue cue) {
if (cue == null) return;
int variant = sequencer.nextVariant(cue);
lastCue = cue;
cueCount++;
ToneGenerator generator = generator(cue.channel());
if (generator == null) return;
try {
generator.startTone(tone(cue, variant), duration(cue, variant));
} catch (RuntimeException ignored) {
// Audio must never interrupt a puzzle on devices with unavailable output routes.
}
}
void updateMusic(float elapsedSeconds, int levelNumber) {
long beat = (long) Math.floor(Math.max(0f, elapsedSeconds) / 0.62f);
if (beat == lastMusicBeat) return;
lastMusicBeat = beat;
if (((beat + Math.max(1, levelNumber)) & 3L) != 3L) play(AudioCue.MUSIC_PULSE);
}
void pause() {
stop(music);
stop(effects);
stop(voice);
lastMusicBeat = Long.MIN_VALUE;
}
void release() {
releaseGenerators();
}
void setSynthesisEnabled(boolean enabled) {
synthesisEnabled = enabled;
if (!enabled) releaseGenerators();
}
AudioCue lastCue() { return lastCue; }
int cueCount() { return cueCount; }
private ToneGenerator generator(AudioCue.Channel channel) {
if (!synthesisEnabled) return null;
switch (channel) {
case MUSIC:
if (music == null) music = create(settings.musicVolume());
return music;
case VOICE:
if (voice == null) voice = create(settings.voiceVolume());
return voice;
case EFFECTS:
default:
if (effects == null) effects = create(settings.effectsVolume());
return effects;
}
}
private static ToneGenerator create(float volume) {
if (volume <= 0f) return null;
try {
return new ToneGenerator(AudioManager.STREAM_MUSIC,
Math.max(0, Math.min(100, Math.round(volume * 100f))));
} catch (RuntimeException ignored) {
return null;
}
}
private static int tone(AudioCue cue, int variant) {
switch (cue) {
case UI_TAP: return ToneGenerator.TONE_PROP_ACK;
case FOOTSTEP: return variant == 0 ? ToneGenerator.TONE_DTMF_1
: variant == 1 ? ToneGenerator.TONE_DTMF_4 : ToneGenerator.TONE_DTMF_7;
case JUMP: return variant == 0 ? ToneGenerator.TONE_DTMF_A
: ToneGenerator.TONE_DTMF_B;
case SWITCH: return variant == 0 ? ToneGenerator.TONE_PROP_BEEP
: variant == 1 ? ToneGenerator.TONE_PROP_BEEP2 : ToneGenerator.TONE_PROP_PROMPT;
case DOOR: return variant == 0 ? ToneGenerator.TONE_CDMA_CONFIRM
: ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD;
case DEATH_IMPACT: return ToneGenerator.TONE_DTMF_0 + Math.min(variant, 5);
case DEATH_VOICE: return ToneGenerator.TONE_DTMF_C + Math.min(variant, 1);
case RESPAWN_VOICE: return variant == 0 ? ToneGenerator.TONE_CDMA_PIP
: variant == 1 ? ToneGenerator.TONE_CDMA_CALL_SIGNAL_ISDN_NORMAL
: ToneGenerator.TONE_CDMA_NETWORK_BUSY;
case GOAL_VOICE: return variant == 0 ? ToneGenerator.TONE_CDMA_CONFIRM
: variant == 1 ? ToneGenerator.TONE_CDMA_ABBR_ALERT
: ToneGenerator.TONE_CDMA_CALLDROP_LITE;
case MUSIC_PULSE:
default:
return ToneGenerator.TONE_DTMF_1 + Math.min(variant * 2, 8);
}
}
private static int duration(AudioCue cue, int variant) {
switch (cue) {
case FOOTSTEP: return 38 + variant * 7;
case JUMP: return 90 + variant * 25;
case SWITCH: return 85 + variant * 20;
case DOOR: return 180;
case DEATH_IMPACT: return 130 + variant * 18;
case DEATH_VOICE: return 260 + variant * 45;
case RESPAWN_VOICE: return 175 + variant * 35;
case GOAL_VOICE: return 320 + variant * 55;
case MUSIC_PULSE: return 105 + variant * 15;
case UI_TAP:
default: return 55;
}
}
private void releaseGenerators() {
release(music);
release(effects);
release(voice);
music = null;
effects = null;
voice = null;
}
private static void stop(ToneGenerator generator) {
if (generator == null) return;
try {
generator.stopTone();
} catch (RuntimeException ignored) {
// Best-effort lifecycle cleanup.
}
}
private static void release(ToneGenerator generator) {
if (generator == null) return;
try {
generator.release();
} catch (RuntimeException ignored) {
// Best-effort lifecycle cleanup.
}
}
}
@@ -5,10 +5,12 @@ import android.content.SharedPreferences;
public final class ProgressStore {
private static final String FILE = "project_sacrifice_progress_v1";
public static final int CURRENT_SAVE_VERSION = 2;
private final SharedPreferences preferences;
public ProgressStore(Context context) {
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
migrateIfSupported();
}
public int unlockedCount() {
@@ -36,6 +38,17 @@ public final class ProgressStore {
editor.apply();
}
public int saveVersion() {
return preferences.getInt("save_version", 1);
}
private void migrateIfSupported() {
int version = preferences.getInt("save_version", 1);
if (version >= 1 && version < CURRENT_SAVE_VERSION) {
preferences.edit().putInt("save_version", CURRENT_SAVE_VERSION).apply();
}
}
private static String key(int levelNumber, String suffix) {
return "level_" + levelNumber + "_" + suffix;
}
@@ -0,0 +1,28 @@
package com.projectsacrifice.game;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/** Stable compact fingerprint for exact deterministic replay comparisons. */
public final class ReplayStateSignature {
private ReplayStateSignature() {}
public static String of(byte[] puzzleState, boolean completed, boolean allDoorsOpen) {
if (puzzleState == null) throw new IllegalArgumentException("Puzzle state is required");
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update((byte) (completed ? 1 : 0));
digest.update((byte) (allDoorsOpen ? 1 : 0));
digest.update(puzzleState);
byte[] bytes = digest.digest();
StringBuilder hex = new StringBuilder(32);
for (int i = 0; i < 16; i++) {
hex.append(Character.forDigit((bytes[i] >>> 4) & 0xf, 16));
hex.append(Character.forDigit(bytes[i] & 0xf, 16));
}
return hex.toString();
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 unavailable", impossible);
}
}
}
@@ -0,0 +1,260 @@
package com.projectsacrifice.game;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Map;
/** Bounded binary codec for Android saved-instance-state; no Java serialization is used. */
public final class VersionedSessionCodec {
private static final int MAGIC = 0x47534e50; // GSNP
private static final int MAX_BODIES = 16;
private static final int MAX_PLATFORMS = 64;
private static final int MAX_MAP_ENTRIES = 128;
public static final int CURRENT_VERSION = 2;
public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT }
public static final class DecodeResult {
private final Status status;
private final GameSessionSnapshot snapshot;
private DecodeResult(Status status, GameSessionSnapshot snapshot) {
this.status = status;
this.snapshot = snapshot;
}
public Status status() { return status; }
public GameSessionSnapshot snapshot() { return snapshot; }
}
private VersionedSessionCodec() {}
public static byte[] encode(GameSessionSnapshot snapshot) {
return encodeWithVersion(snapshot, CURRENT_VERSION);
}
static byte[] encodeLegacyV1(GameSessionSnapshot snapshot) {
return encodeWithVersion(snapshot, 1);
}
static byte[] encodeVersionForTest(int version) {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
DataOutputStream output = new DataOutputStream(bytes);
output.writeInt(MAGIC);
output.writeInt(version);
output.flush();
return bytes.toByteArray();
} catch (IOException impossible) {
throw new IllegalStateException(impossible);
}
}
public static DecodeResult decode(byte[] encoded) {
if (encoded == null || encoded.length < 8) return result(Status.CORRUPT, null);
try {
DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded));
if (input.readInt() != MAGIC) return result(Status.CORRUPT, null);
int version = input.readInt();
if (version > CURRENT_VERSION) return result(Status.UNSUPPORTED, null);
if (version < 1) return result(Status.CORRUPT, null);
int levelNumber = input.readInt();
if (levelNumber < 1 || levelNumber > 10) return result(Status.CORRUPT, null);
GameSessionSnapshot.Builder builder = GameSessionSnapshot.builder(levelNumber)
.saveVersion(CURRENT_VERSION)
.elapsedSeconds(finiteNonNegative(input.readFloat()))
.deaths(bounded(input.readInt(), 0, 10_000))
.respawnSeconds(finiteNonNegative(input.readFloat()))
.player(readPlayer(input));
int bodyCount = checkedCount(input.readInt(), MAX_BODIES);
for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input));
int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS);
ArrayList<Float> motionTimes = new ArrayList<>(platformCount);
for (int i = 0; i < platformCount; i++) {
motionTimes.add(finiteNonNegative(input.readFloat()));
}
builder.platformMotionTimes(motionTimes);
readBooleanMap(input, builder, 0);
readFloatMap(input, builder);
if (version >= 2) readBooleanMap(input, builder, 1);
if (input.available() != 0) return result(Status.CORRUPT, null);
GameSessionSnapshot snapshot = builder.build();
return result(version == CURRENT_VERSION ? Status.RESTORED : Status.MIGRATED,
snapshot);
} catch (IOException | IllegalArgumentException failure) {
return result(Status.CORRUPT, null);
}
}
private static byte[] encodeWithVersion(GameSessionSnapshot snapshot, int version) {
if (snapshot == null) throw new IllegalArgumentException("Snapshot is required");
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
DataOutputStream output = new DataOutputStream(bytes);
output.writeInt(MAGIC);
output.writeInt(version);
output.writeInt(snapshot.levelNumber());
output.writeFloat(snapshot.elapsedSeconds());
output.writeInt(snapshot.deaths());
output.writeFloat(snapshot.respawnSeconds());
writePlayer(output, snapshot.player());
output.writeInt(snapshot.bodies().size());
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) writeBody(output, body);
output.writeInt(snapshot.platformMotionTimes().size());
for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time);
writeBooleanMap(output, snapshot.toggleSignals());
writeFloatMap(output, snapshot.timedSignals());
if (version >= 2) writeBooleanMap(output, snapshot.previousSwitchContacts());
output.flush();
return bytes.toByteArray();
} catch (IOException impossible) {
throw new IllegalStateException(impossible);
}
}
private static void writePlayer(DataOutputStream output, GameSessionSnapshot.PlayerData data)
throws IOException {
output.writeFloat(data.x());
output.writeFloat(data.y());
output.writeFloat(data.velocityX());
output.writeFloat(data.velocityY());
output.writeBoolean(data.alive());
output.writeBoolean(data.onGround());
output.writeFloat(data.coyoteSeconds());
output.writeFloat(data.jumpBufferSeconds());
output.writeInt(data.supportIndex());
output.writeInt(data.facing());
}
private static GameSessionSnapshot.PlayerData readPlayer(DataInputStream input)
throws IOException {
return new GameSessionSnapshot.PlayerData(
finite(input.readFloat()), finite(input.readFloat()),
finite(input.readFloat()), finite(input.readFloat()),
input.readBoolean(), input.readBoolean(),
finiteNonNegative(input.readFloat()), finiteNonNegative(input.readFloat()),
input.readInt(), input.readInt());
}
private static void writeBody(DataOutputStream output, GameSessionSnapshot.BodyData body)
throws IOException {
output.writeFloat(body.x());
output.writeFloat(body.y());
output.writeFloat(body.width());
output.writeFloat(body.height());
output.writeFloat(body.velocityX());
output.writeFloat(body.velocityY());
output.writeBoolean(body.frozen());
output.writeBoolean(body.conductive());
output.writeBoolean(body.pinned());
output.writeBoolean(body.grounded());
output.writeInt(body.supportIndex());
output.writeUTF(body.deathType());
}
private static GameSessionSnapshot.BodyData readBody(DataInputStream input)
throws IOException {
float x = finite(input.readFloat());
float y = finite(input.readFloat());
float width = finitePositive(input.readFloat());
float height = finitePositive(input.readFloat());
float velocityX = finite(input.readFloat());
float velocityY = finite(input.readFloat());
boolean frozen = input.readBoolean();
boolean conductive = input.readBoolean();
boolean pinned = input.readBoolean();
boolean grounded = input.readBoolean();
int support = input.readInt();
String deathType = input.readUTF();
if (deathType.length() > 24) throw new IllegalArgumentException("Death type too long");
return new GameSessionSnapshot.BodyData(x, y, width, height,
velocityX, velocityY, frozen, conductive, pinned, grounded,
support, deathType);
}
private static void writeBooleanMap(DataOutputStream output, Map<String, Boolean> values)
throws IOException {
output.writeInt(values.size());
for (Map.Entry<String, Boolean> entry : values.entrySet()) {
output.writeUTF(entry.getKey());
output.writeBoolean(Boolean.TRUE.equals(entry.getValue()));
}
}
private static void readBooleanMap(DataInputStream input,
GameSessionSnapshot.Builder builder, int destination)
throws IOException {
int count = checkedCount(input.readInt(), MAX_MAP_ENTRIES);
for (int i = 0; i < count; i++) {
String key = checkedKey(input.readUTF());
boolean value = input.readBoolean();
if (destination == 0) builder.toggleSignal(key, value);
else builder.previousSwitchContact(key, value);
}
}
private static void writeFloatMap(DataOutputStream output, Map<String, Float> values)
throws IOException {
output.writeInt(values.size());
for (Map.Entry<String, Float> entry : values.entrySet()) {
output.writeUTF(entry.getKey());
output.writeFloat(entry.getValue());
}
}
private static void readFloatMap(DataInputStream input, GameSessionSnapshot.Builder builder)
throws IOException {
int count = checkedCount(input.readInt(), MAX_MAP_ENTRIES);
for (int i = 0; i < count; i++) {
builder.timedSignal(checkedKey(input.readUTF()),
finiteNonNegative(input.readFloat()));
}
}
private static int checkedCount(int value, int maximum) {
if (value < 0 || value > maximum) throw new IllegalArgumentException("Invalid count");
return value;
}
private static String checkedKey(String value) {
if (value == null || value.isEmpty() || value.length() > 128) {
throw new IllegalArgumentException("Invalid state key");
}
return value;
}
private static int bounded(int value, int minimum, int maximum) {
if (value < minimum || value > maximum) throw new IllegalArgumentException("Out of range");
return value;
}
private static float finite(float value) {
if (Float.isNaN(value) || Float.isInfinite(value)) {
throw new IllegalArgumentException("Non-finite value");
}
return value;
}
private static float finiteNonNegative(float value) {
value = finite(value);
if (value < 0f) throw new IllegalArgumentException("Negative value");
return value;
}
private static float finitePositive(float value) {
value = finite(value);
if (value <= 0f) throw new IllegalArgumentException("Non-positive value");
return value;
}
private static DecodeResult result(Status status, GameSessionSnapshot snapshot) {
return new DecodeResult(status, snapshot);
}
}
@@ -0,0 +1,199 @@
package com.projectsacrifice.game;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
/** Canvas-only adapter for the normalized volunteer skeleton. */
final class VolunteerCanvasRenderer {
private static final int FRAME_COUNT = 16;
private static final VolunteerRig.Pose[][][][] POSES = buildPoseCache();
void draw(Canvas canvas, Paint fill, Paint outline, Path path,
float x, float y, float width, float height,
VolunteerRig.Skin skin, VolunteerRig.Motion motion,
float phase, int facing, float alpha, boolean highContrast) {
VolunteerRig.Pose pose = cachedPose(skin, motion, phase, facing);
int frameColor = highContrast ? Color.WHITE : pose.palette().frame();
int shellColor = highContrast ? Color.rgb(255, 214, 77) : pose.palette().shell();
int clothColor = highContrast ? Color.WHITE : pose.palette().cloth();
int glowColor = highContrast ? Color.BLACK : pose.palette().glow();
outline.setStyle(Paint.Style.STROKE);
outline.setStrokeCap(Paint.Cap.ROUND);
outline.setStrokeWidth(Math.max(3f, width * 0.075f));
outline.setColor(withAlpha(frameColor, alpha));
line(canvas, outline, pose, VolunteerRig.Joint.HIPS, VolunteerRig.Joint.CHEST,
x, y, width, height);
line(canvas, outline, pose, VolunteerRig.Joint.LEFT_SHOULDER,
VolunteerRig.Joint.LEFT_HAND, x, y, width, height);
line(canvas, outline, pose, VolunteerRig.Joint.RIGHT_SHOULDER,
VolunteerRig.Joint.RIGHT_HAND, x, y, width, height);
line(canvas, outline, pose, VolunteerRig.Joint.HIPS,
VolunteerRig.Joint.LEFT_KNEE, x, y, width, height);
line(canvas, outline, pose, VolunteerRig.Joint.LEFT_KNEE,
VolunteerRig.Joint.LEFT_FOOT, x, y, width, height);
line(canvas, outline, pose, VolunteerRig.Joint.HIPS,
VolunteerRig.Joint.RIGHT_KNEE, x, y, width, height);
line(canvas, outline, pose, VolunteerRig.Joint.RIGHT_KNEE,
VolunteerRig.Joint.RIGHT_FOOT, x, y, width, height);
float chestX = px(pose, VolunteerRig.Joint.CHEST, x, width);
float chestY = py(pose, VolunteerRig.Joint.CHEST, y, height);
float hipsX = px(pose, VolunteerRig.Joint.HIPS, x, width);
float hipsY = py(pose, VolunteerRig.Joint.HIPS, y, height);
float headX = px(pose, VolunteerRig.Joint.HEAD, x, width);
float headY = py(pose, VolunteerRig.Joint.HEAD, y, height);
fill.setColor(withAlpha(clothColor, alpha * 0.86f));
path.reset();
path.moveTo(chestX - width * 0.22f * facing, chestY + height * 0.04f);
path.lineTo(hipsX - width * 0.36f * facing, hipsY + height * 0.17f);
path.lineTo(hipsX, hipsY + height * 0.06f);
path.close();
canvas.drawPath(path, fill);
fill.setColor(withAlpha(shellColor, alpha));
canvas.drawRoundRect(chestX - width * 0.22f, chestY - height * 0.08f,
chestX + width * 0.22f, hipsY + height * 0.04f,
width * 0.13f, width * 0.13f, fill);
canvas.drawCircle(headX, headY, width * 0.22f, fill);
outline.setStrokeWidth(Math.max(2f, width * 0.045f));
canvas.drawCircle(headX, headY, width * 0.22f, outline);
canvas.drawRoundRect(chestX - width * 0.22f, chestY - height * 0.08f,
chestX + width * 0.22f, hipsY + height * 0.04f,
width * 0.13f, width * 0.13f, outline);
fill.setColor(withAlpha(pose.visorLit() ? glowColor : frameColor,
pose.visorLit() ? alpha : alpha * 0.72f));
canvas.drawRoundRect(headX - width * 0.14f, headY - height * 0.025f,
headX + width * 0.14f, headY + height * 0.025f,
width * 0.025f, width * 0.025f, fill);
drawJoint(canvas, fill, pose, VolunteerRig.Joint.LEFT_SHOULDER,
x, y, width, height, frameColor, alpha);
drawJoint(canvas, fill, pose, VolunteerRig.Joint.RIGHT_SHOULDER,
x, y, width, height, frameColor, alpha);
drawJoint(canvas, fill, pose, VolunteerRig.Joint.LEFT_KNEE,
x, y, width, height, frameColor, alpha);
drawJoint(canvas, fill, pose, VolunteerRig.Joint.RIGHT_KNEE,
x, y, width, height, frameColor, alpha);
if (pose.anchorBolt()) {
fill.setColor(withAlpha(glowColor, alpha));
canvas.drawCircle(hipsX, hipsY, width * 0.14f, fill);
outline.setColor(withAlpha(frameColor, alpha));
canvas.drawLine(hipsX - width * 0.1f, hipsY, hipsX + width * 0.1f, hipsY, outline);
canvas.drawLine(hipsX, hipsY - width * 0.1f, hipsX, hipsY + width * 0.1f, outline);
}
if (pose.frozenShell()) drawFrozenShell(canvas, fill, outline, x, y, width, height, alpha);
if (pose.conductiveArcs()) drawConductiveArcs(canvas, outline, path,
x, y, width, height, phase, alpha);
if (motion == VolunteerRig.Motion.RESPAWN) drawAssemblyRings(canvas, outline,
x, y, width, height, pose.assemblyProgress(), alpha);
}
private static void drawFrozenShell(Canvas canvas, Paint fill, Paint outline,
float x, float y, float width, float height, float alpha) {
fill.setColor(withAlpha(Color.rgb(85, 205, 236), alpha * 0.34f));
canvas.drawRoundRect(x - width * 0.12f, y + height * 0.02f,
x + width * 1.12f, y + height,
width * 0.12f, width * 0.12f, fill);
outline.setColor(withAlpha(Color.rgb(218, 252, 255), alpha));
outline.setStrokeWidth(Math.max(2f, width * 0.045f));
canvas.drawLine(x, y + height * 0.18f, x + width * 0.28f, y + height * 0.45f, outline);
canvas.drawLine(x + width, y + height * 0.16f,
x + width * 0.72f, y + height * 0.52f, outline);
}
private static void drawConductiveArcs(Canvas canvas, Paint outline, Path path,
float x, float y, float width, float height,
float phase, float alpha) {
outline.setColor(withAlpha(Color.rgb(255, 239, 74), alpha));
outline.setStrokeWidth(Math.max(3f, width * 0.055f));
float pulse = ((int) (phase * 12f) & 1) == 0 ? 0f : width * 0.06f;
path.reset();
path.moveTo(x - width * 0.15f, y + height * 0.3f);
path.lineTo(x + width * 0.08f, y + height * 0.22f + pulse);
path.lineTo(x - width * 0.02f, y + height * 0.52f);
path.lineTo(x + width * 0.18f, y + height * 0.68f - pulse);
canvas.drawPath(path, outline);
path.reset();
path.moveTo(x + width * 1.15f, y + height * 0.28f);
path.lineTo(x + width * 0.91f, y + height * 0.43f - pulse);
path.lineTo(x + width * 1.03f, y + height * 0.66f);
path.lineTo(x + width * 0.82f, y + height * 0.77f + pulse);
canvas.drawPath(path, outline);
}
private static void drawAssemblyRings(Canvas canvas, Paint outline,
float x, float y, float width, float height,
float progress, float alpha) {
outline.setColor(withAlpha(Color.rgb(116, 232, 213), alpha * (1f - progress * 0.3f)));
outline.setStrokeWidth(Math.max(2f, width * 0.045f));
float cx = x + width * 0.5f;
float cy = y + height * 0.55f;
canvas.drawCircle(cx, cy, width * (0.25f + progress * 0.45f), outline);
canvas.drawCircle(cx, cy, width * (0.48f + progress * 0.35f), outline);
}
private static void drawJoint(Canvas canvas, Paint fill, VolunteerRig.Pose pose,
VolunteerRig.Joint joint, float x, float y,
float width, float height, int color, float alpha) {
fill.setColor(withAlpha(color, alpha));
canvas.drawCircle(px(pose, joint, x, width), py(pose, joint, y, height),
Math.max(2.5f, width * 0.065f), fill);
}
private static void line(Canvas canvas, Paint outline, VolunteerRig.Pose pose,
VolunteerRig.Joint from, VolunteerRig.Joint to,
float x, float y, float width, float height) {
canvas.drawLine(px(pose, from, x, width), py(pose, from, y, height),
px(pose, to, x, width), py(pose, to, y, height), outline);
}
private static float px(VolunteerRig.Pose pose, VolunteerRig.Joint joint,
float x, float width) {
return x + pose.x(joint) * width;
}
private static float py(VolunteerRig.Pose pose, VolunteerRig.Joint joint,
float y, float height) {
return y + pose.y(joint) * height;
}
private static VolunteerRig.Pose cachedPose(VolunteerRig.Skin skin,
VolunteerRig.Motion motion,
float phase, int facing) {
int frame = Math.min(FRAME_COUNT - 1,
Math.max(0, (int) ((phase - Math.floor(phase)) * FRAME_COUNT)));
return POSES[skin.ordinal()][motion.ordinal()][facing < 0 ? 0 : 1][frame];
}
private static VolunteerRig.Pose[][][][] buildPoseCache() {
VolunteerRig.Skin[] skins = VolunteerRig.Skin.values();
VolunteerRig.Motion[] motions = VolunteerRig.Motion.values();
VolunteerRig.Pose[][][][] result =
new VolunteerRig.Pose[skins.length][motions.length][2][FRAME_COUNT];
for (VolunteerRig.Skin skin : skins) {
for (VolunteerRig.Motion motion : motions) {
for (int facingIndex = 0; facingIndex < 2; facingIndex++) {
for (int frame = 0; frame < FRAME_COUNT; frame++) {
result[skin.ordinal()][motion.ordinal()][facingIndex][frame] =
VolunteerRig.pose(skin, motion, frame / (float) (FRAME_COUNT - 1),
facingIndex == 0 ? -1 : 1);
}
}
}
}
return result;
}
private static int withAlpha(int color, float alpha) {
int clamped = Math.max(0, Math.min(255, Math.round(Color.alpha(color) * alpha)));
return Color.argb(clamped, Color.red(color), Color.green(color), Color.blue(color));
}
}
@@ -0,0 +1,210 @@
package com.projectsacrifice.game;
import java.util.Arrays;
/**
* Skin-independent normalized skeleton for the volunteer renderer.
* Gameplay collision remains external and is never derived from these points.
*/
public final class VolunteerRig {
public static final float COLLISION_WIDTH = 62f;
public static final float COLLISION_HEIGHT = 100f;
public enum Joint {
ROOT,
HIPS,
CHEST,
HEAD,
LEFT_SHOULDER,
RIGHT_SHOULDER,
LEFT_HAND,
RIGHT_HAND,
LEFT_KNEE,
RIGHT_KNEE,
LEFT_FOOT,
RIGHT_FOOT
}
public enum Skin {
BRASS_SCOUT(new Palette(0xffd5a64c, 0xff415064, 0xffb33f49, 0xffffd96b)),
VERDIGRIS_KEEPER(new Palette(0xff69a99c, 0xff263f49, 0xffd09d54, 0xffc8fff1)),
EMBER_COURIER(new Palette(0xffd87648, 0xff4b3440, 0xff6e95bb, 0xffffe09a));
private final Palette palette;
Skin(Palette palette) {
this.palette = palette;
}
public Palette palette() {
return palette;
}
public static Skin forLevel(int oneBasedLevelNumber) {
int index = Math.max(1, oneBasedLevelNumber) - 1;
return values()[index % values().length];
}
}
public enum Motion {
IDLE,
RUN,
JUMP,
FALL,
DEAD,
PINNED,
FROZEN,
CONDUCTIVE,
RESPAWN
}
public static final class Palette {
private final int shell;
private final int frame;
private final int cloth;
private final int glow;
private Palette(int shell, int frame, int cloth, int glow) {
this.shell = shell;
this.frame = frame;
this.cloth = cloth;
this.glow = glow;
}
public int shell() { return shell; }
public int frame() { return frame; }
public int cloth() { return cloth; }
public int glow() { return glow; }
}
public static final class Pose {
private final float[] x;
private final float[] y;
private final Palette palette;
private final Motion motion;
private final boolean visorLit;
private final float assemblyProgress;
private Pose(float[] x, float[] y, Palette palette, Motion motion,
boolean visorLit, float assemblyProgress) {
this.x = Arrays.copyOf(x, x.length);
this.y = Arrays.copyOf(y, y.length);
this.palette = palette;
this.motion = motion;
this.visorLit = visorLit;
this.assemblyProgress = assemblyProgress;
}
public int jointCount() { return x.length; }
public float x(Joint joint) { return x[joint.ordinal()]; }
public float y(Joint joint) { return y[joint.ordinal()]; }
public Palette palette() { return palette; }
public Motion motion() { return motion; }
public float collisionWidth() { return COLLISION_WIDTH; }
public float collisionHeight() { return COLLISION_HEIGHT; }
public boolean visorLit() { return visorLit; }
public boolean frozenShell() { return motion == Motion.FROZEN; }
public boolean conductiveArcs() { return motion == Motion.CONDUCTIVE; }
public boolean anchorBolt() { return motion == Motion.PINNED; }
public float assemblyProgress() { return assemblyProgress; }
}
private VolunteerRig() {}
public static Motion aliveMotion(boolean onGround, float horizontalVelocity,
float verticalVelocity) {
if (!onGround) return verticalVelocity < 0f ? Motion.JUMP : Motion.FALL;
return Math.abs(horizontalVelocity) >= 30f ? Motion.RUN : Motion.IDLE;
}
public static Motion bodyMotion(boolean frozen, boolean conductive, boolean pinned) {
if (frozen) return Motion.FROZEN;
if (conductive) return Motion.CONDUCTIVE;
if (pinned) return Motion.PINNED;
return Motion.DEAD;
}
public static Pose pose(Skin skin, Motion motion, float phase, int facing) {
Skin safeSkin = skin == null ? Skin.BRASS_SCOUT : skin;
Motion safeMotion = motion == null ? Motion.IDLE : motion;
float cycle = phase - (float) Math.floor(phase);
float swing = (float) Math.sin(cycle * Math.PI * 2f);
float bounce = (float) Math.sin(cycle * Math.PI * 2f) * 0.018f;
float[] xs = {
0.50f, 0.50f, 0.50f, 0.50f,
0.34f, 0.66f, 0.25f, 0.75f,
0.40f, 0.60f, 0.31f, 0.69f
};
float[] ys = {
0.96f, 0.67f, 0.43f, 0.20f,
0.43f, 0.43f, 0.66f, 0.66f,
0.79f, 0.79f, 0.96f, 0.96f
};
switch (safeMotion) {
case IDLE:
ys[Joint.CHEST.ordinal()] += bounce;
ys[Joint.HEAD.ordinal()] += bounce;
break;
case RUN:
xs[Joint.LEFT_HAND.ordinal()] += swing * 0.18f;
xs[Joint.RIGHT_HAND.ordinal()] -= swing * 0.18f;
xs[Joint.LEFT_KNEE.ordinal()] -= swing * 0.13f;
xs[Joint.RIGHT_KNEE.ordinal()] += swing * 0.13f;
xs[Joint.LEFT_FOOT.ordinal()] -= swing * 0.18f;
xs[Joint.RIGHT_FOOT.ordinal()] += swing * 0.18f;
ys[Joint.ROOT.ordinal()] -= Math.abs(swing) * 0.025f;
break;
case JUMP:
xs[Joint.LEFT_HAND.ordinal()] = 0.16f;
xs[Joint.RIGHT_HAND.ordinal()] = 0.84f;
ys[Joint.LEFT_HAND.ordinal()] = 0.35f;
ys[Joint.RIGHT_HAND.ordinal()] = 0.35f;
xs[Joint.LEFT_FOOT.ordinal()] = 0.42f;
xs[Joint.RIGHT_FOOT.ordinal()] = 0.58f;
ys[Joint.LEFT_FOOT.ordinal()] = 0.88f;
ys[Joint.RIGHT_FOOT.ordinal()] = 0.88f;
break;
case FALL:
xs[Joint.LEFT_HAND.ordinal()] = 0.18f;
xs[Joint.RIGHT_HAND.ordinal()] = 0.82f;
ys[Joint.LEFT_HAND.ordinal()] = 0.51f;
ys[Joint.RIGHT_HAND.ordinal()] = 0.51f;
xs[Joint.LEFT_FOOT.ordinal()] = 0.23f;
xs[Joint.RIGHT_FOOT.ordinal()] = 0.77f;
break;
case DEAD:
case PINNED:
case FROZEN:
case CONDUCTIVE:
xs[Joint.LEFT_HAND.ordinal()] = 0.15f;
xs[Joint.RIGHT_HAND.ordinal()] = 0.85f;
ys[Joint.LEFT_HAND.ordinal()] = 0.72f;
ys[Joint.RIGHT_HAND.ordinal()] = 0.72f;
xs[Joint.LEFT_FOOT.ordinal()] = 0.24f;
xs[Joint.RIGHT_FOOT.ordinal()] = 0.76f;
break;
case RESPAWN:
float assembly = clamp01(phase);
for (int i = 0; i < ys.length; i++) {
ys[i] = 1f - (1f - ys[i]) * assembly;
}
break;
}
if (facing < 0) {
for (int i = 0; i < xs.length; i++) xs[i] = 1f - xs[i];
}
boolean visorLit = safeMotion != Motion.DEAD
&& safeMotion != Motion.PINNED
&& safeMotion != Motion.FROZEN
&& safeMotion != Motion.CONDUCTIVE;
float assemblyProgress = safeMotion == Motion.RESPAWN ? clamp01(phase) : 1f;
return new Pose(xs, ys, safeSkin.palette(), safeMotion, visorLit, assemblyProgress);
}
private static float clamp01(float value) {
return Math.max(0f, Math.min(1f, value));
}
}
@@ -50,4 +50,17 @@ public final class AccessibilitySettingsTest {
assertEquals(24f, AccessibilitySettings.defaults().canvasTextSize(24f), 0f);
}
@Test
public void reducedMotionIsExplicitReversibleAndRestorable() {
AccessibilitySettings settings = AccessibilitySettings.defaults();
assertFalse(settings.reducedMotionEnabled());
assertEquals("Off", settings.reducedMotionLabel());
settings = settings.toggleReducedMotion();
assertTrue(settings.reducedMotionEnabled());
assertEquals("On", settings.reducedMotionLabel());
assertFalse(settings.toggleReducedMotion().reducedMotionEnabled());
assertTrue(AccessibilitySettings.restore(0, false, true).reducedMotionEnabled());
}
}
@@ -0,0 +1,31 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import org.junit.Test;
public final class AudioCueSequencerTest {
@Test
public void multiVariantDeathVoicesNeverRepeatImmediately() {
AudioCueSequencer sequencer = new AudioCueSequencer(17L);
int previous = sequencer.nextVariant(AudioCue.DEATH_VOICE);
for (int i = 0; i < 30; i++) {
int next = sequencer.nextVariant(AudioCue.DEATH_VOICE);
assertNotEquals(previous, next);
previous = next;
}
}
@Test
public void singleVariantCuesRemainStableAndChannelsAreExplicit() {
AudioCueSequencer sequencer = new AudioCueSequencer(9L);
assertEquals(0, sequencer.nextVariant(AudioCue.UI_TAP));
assertEquals(0, sequencer.nextVariant(AudioCue.UI_TAP));
assertEquals(AudioCue.Channel.EFFECTS, AudioCue.JUMP.channel());
assertEquals(AudioCue.Channel.VOICE, AudioCue.DEATH_VOICE.channel());
assertEquals(AudioCue.Channel.MUSIC, AudioCue.MUSIC_PULSE.channel());
}
}
@@ -0,0 +1,35 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public final class AudioSettingsTest {
@Test
public void musicEffectsAndVoiceVolumesCycleIndependently() {
AudioSettings settings = AudioSettings.defaults();
assertEquals(0.66f, settings.musicVolume(), 0f);
assertEquals(1f, settings.effectsVolume(), 0f);
assertEquals(1f, settings.voiceVolume(), 0f);
AudioSettings quieterMusic = settings.nextMusicVolume();
assertEquals(1f, quieterMusic.musicVolume(), 0f);
assertEquals(settings.effectsVolume(), quieterMusic.effectsVolume(), 0f);
assertEquals(settings.voiceVolume(), quieterMusic.voiceVolume(), 0f);
AudioSettings mutedEffects = settings.nextEffectsVolume();
assertEquals(0f, mutedEffects.effectsVolume(), 0f);
assertEquals(settings.musicVolume(), mutedEffects.musicVolume(), 0f);
}
@Test
public void invalidPersistedPresetsRestoreToSafeDefaults() {
AudioSettings restored = AudioSettings.restore(-4, 99, 2);
assertEquals(AudioSettings.defaults().musicPreset(), restored.musicPreset());
assertEquals(AudioSettings.defaults().effectsPreset(), restored.effectsPreset());
assertEquals(2, restored.voicePreset());
assertEquals("66%", restored.volumeLabel(restored.voicePreset()));
}
}
@@ -0,0 +1,57 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class CameraControllerTest {
@Test
public void followUsesHorizontalAndVerticalDampingWithinWorldBounds() {
CameraController camera = new CameraController(1_920f, 1_080f);
camera.reset();
CameraController.Frame frame = camera.update(
0.25f, 1_600f, 1_250f, 1, 3_200f, 1_800f, false);
assertTrue(frame.x() > 0f && frame.x() <= 1_280f);
assertTrue(frame.y() > 0f && frame.y() <= 720f);
CameraController.Frame clamped = camera.update(
5f, 9_000f, 9_000f, 1, 3_200f, 1_800f, false);
assertTrue(clamped.x() <= 1_280f);
assertTrue(clamped.y() <= 720f);
}
@Test
public void temporaryFocusIsBoundedAndExpiresBackToPlayerFollow() {
CameraController camera = new CameraController(1_920f, 1_080f);
camera.reset();
camera.requestFocus(2_500f, 400f, 0.35f);
CameraController.Frame focused = camera.update(
0.1f, 700f, 780f, 1, 3_200f, 1_080f, false);
assertTrue(focused.focusActive());
assertTrue(focused.focusOffsetX() <= CameraController.MAX_FOCUS_OFFSET);
CameraController.Frame expired = camera.update(
0.4f, 700f, 780f, 1, 3_200f, 1_080f, false);
assertFalse(expired.focusActive());
}
@Test
public void impactShakeIsBoundedAndReducedMotionDisablesIt() {
CameraController camera = new CameraController(1_920f, 1_080f);
camera.reset();
camera.impact(24f);
CameraController.Frame shaking = camera.update(
0.016f, 700f, 780f, 1, 3_200f, 1_080f, false);
assertTrue(Math.abs(shaking.shakeX()) <= CameraController.MAX_SHAKE);
assertTrue(Math.abs(shaking.shakeY()) <= CameraController.MAX_SHAKE);
CameraController.Frame reduced = camera.update(
0.016f, 700f, 780f, 1, 3_200f, 1_080f, true);
assertTrue(reduced.shakeX() == 0f);
assertTrue(reduced.shakeY() == 0f);
}
}
@@ -0,0 +1,69 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import org.junit.Test;
public final class DeterministicInputTapeTest {
@Test
public void oneRecordedCommandTapeReplaysToTheSameTerminalSignature() {
DeterministicInputTape tape = DeterministicInputTape.builder(2)
.record(false, true, false, 16L)
.record(false, true, true, 32L)
.record(false, false, false, 16L)
.build();
FakeTarget first = new FakeTarget();
FakeTarget second = new FakeTarget();
assertEquals(tape.play(first), tape.play(second));
assertEquals("level=2;ticks=64;mask=0", first.terminalSignature());
assertEquals(3, tape.commands().size());
}
@Test
public void tapeIdIsStableButChangesWithCommands() {
DeterministicInputTape first = DeterministicInputTape.builder(1)
.record(false, true, false, 16L).build();
DeterministicInputTape same = DeterministicInputTape.builder(1)
.record(false, true, false, 16L).build();
DeterministicInputTape changed = DeterministicInputTape.builder(1)
.record(false, true, true, 16L).build();
assertEquals(first.id(), same.id());
assertNotEquals(first.id(), changed.id());
}
@Test
public void terminalStateSignatureIncludesPuzzleBytesAndCompletionFlags() {
byte[] puzzle = {4, 8, 15, 16, 23, 42};
assertEquals(ReplayStateSignature.of(puzzle, true, false),
ReplayStateSignature.of(puzzle, true, false));
assertNotEquals(ReplayStateSignature.of(puzzle, true, false),
ReplayStateSignature.of(puzzle, false, false));
}
private static final class FakeTarget implements DeterministicInputTape.Target {
private int level;
private long ticks;
private int mask;
@Override
public void startLevel(int oneBasedLevelNumber) { level = oneBasedLevelNumber; }
@Override
public void setInput(boolean left, boolean right, boolean jump) {
mask = (left ? 1 : 0) | (right ? 2 : 0) | (jump ? 4 : 0);
}
@Override
public void advance(long milliseconds) { ticks += milliseconds; }
@Override
public String terminalSignature() {
return "level=" + level + ";ticks=" + ticks + ";mask=" + mask;
}
}
}
@@ -0,0 +1,33 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class PerformanceMonitorTest {
@Test
public void rollingP95AndComplexityAreComparedWithPublishedBudgets() {
PerformanceMonitor monitor = new PerformanceMonitor(240);
for (int frame = 0; frame < 240; frame++) {
monitor.recordFrame(16f, 3f, 7f);
monitor.recordComplexity(6, 180, 900, 48L * 1024L * 1024L);
}
PerformanceMonitor.Snapshot healthy = monitor.snapshot();
assertEquals(240, healthy.sampleCount());
assertEquals(16f, healthy.p95FrameIntervalMs(), 0f);
assertTrue(healthy.within(PerformanceBudget.release()));
assertEquals(6, healthy.maximumBodies());
for (int frame = 0; frame < 24; frame++) {
monitor.recordFrame(75f, 18f, 30f);
monitor.recordComplexity(17, 4_000, 3_000, 140L * 1024L * 1024L);
}
PerformanceMonitor.Snapshot overloaded = monitor.snapshot();
assertFalse(overloaded.within(PerformanceBudget.release()));
assertTrue(overloaded.failureSummary(PerformanceBudget.release()).contains("frameP95"));
assertTrue(overloaded.failureSummary(PerformanceBudget.release()).contains("bodies"));
}
}
@@ -0,0 +1,23 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class PhysicsConfigTest {
@Test
public void releasePhysicsIsOneVersionedFiniteSourceOfTruth() {
PhysicsConfig physics = PhysicsConfig.release();
assertEquals(1, physics.version());
assertEquals(1f / 60f, physics.fixedStepSeconds(), 0f);
assertEquals(1_800f, physics.gravity(), 0f);
assertEquals(370f, physics.runSpeed(), 0f);
assertEquals(880f, physics.jumpSpeed(), 0f);
assertEquals(0.13f, physics.coyoteSeconds(), 0f);
assertEquals(0.14f, physics.jumpBufferSeconds(), 0f);
assertEquals(0.78f, physics.respawnSeconds(), 0f);
assertTrue(physics.maximumBodies() >= 10);
}
}
@@ -0,0 +1,81 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.util.Arrays;
import org.junit.Test;
public final class VersionedSessionCodecTest {
@Test
public void currentSnapshotRoundTripsEveryPuzzleCriticalField() {
GameSessionSnapshot original = sampleSnapshot();
VersionedSessionCodec.DecodeResult decoded =
VersionedSessionCodec.decode(VersionedSessionCodec.encode(original));
assertEquals(VersionedSessionCodec.Status.RESTORED, decoded.status());
GameSessionSnapshot restored = decoded.snapshot();
assertEquals(5, restored.levelNumber());
assertEquals(12.75f, restored.elapsedSeconds(), 0f);
assertEquals(2, restored.deaths());
assertEquals(640f, restored.player().x(), 0f);
assertFalse(restored.player().alive());
assertEquals(2, restored.bodies().size());
assertTrue(restored.bodies().get(0).pinned());
assertTrue(restored.bodies().get(1).conductive());
assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes());
assertTrue(restored.toggleSignals().get("gate"));
assertEquals(2.25f, restored.timedSignals().get("clock"), 0f);
assertTrue(restored.previousSwitchContacts().get("gate@420.0"));
}
@Test
public void legacyV1MigratesWithSafeEmptyEdgeContacts() {
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
VersionedSessionCodec.encodeLegacyV1(sampleSnapshot()));
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
assertEquals(VersionedSessionCodec.CURRENT_VERSION,
decoded.snapshot().saveVersion());
assertTrue(decoded.snapshot().previousSwitchContacts().isEmpty());
assertEquals(2, decoded.snapshot().bodies().size());
}
@Test
public void corruptAndFutureSnapshotsFailClosed() {
VersionedSessionCodec.DecodeResult corrupt =
VersionedSessionCodec.decode(new byte[] {1, 2, 3});
VersionedSessionCodec.DecodeResult future =
VersionedSessionCodec.decode(VersionedSessionCodec.encodeVersionForTest(99));
assertEquals(VersionedSessionCodec.Status.CORRUPT, corrupt.status());
assertNull(corrupt.snapshot());
assertEquals(VersionedSessionCodec.Status.UNSUPPORTED, future.status());
assertNull(future.snapshot());
}
private static GameSessionSnapshot sampleSnapshot() {
return GameSessionSnapshot.builder(5)
.elapsedSeconds(12.75f)
.deaths(2)
.respawnSeconds(0.4f)
.player(new GameSessionSnapshot.PlayerData(
640f, 780f, 120f, -40f, false, false,
0.1f, 0.08f, -1, -1))
.addBody(new GameSessionSnapshot.BodyData(
500f, 826f, 72f, 54f, 0f, 0f,
false, false, true, true, -1, "SPIKE"))
.addBody(new GameSessionSnapshot.BodyData(
720f, 826f, 72f, 54f, 0f, 0f,
false, true, true, true, -1, "ELECTRIC"))
.platformMotionTimes(Arrays.asList(1.25f, 3.5f))
.toggleSignal("gate", true)
.timedSignal("clock", 2.25f)
.previousSwitchContact("gate@420.0", true)
.build();
}
}
@@ -0,0 +1,87 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class VolunteerRigTest {
@Test
public void skinsReuseTheSameNamedSkeletonAndOnlyChangePalette() {
VolunteerRig.Pose brass = VolunteerRig.pose(
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.IDLE, 0f, 1);
VolunteerRig.Pose verdigris = VolunteerRig.pose(
VolunteerRig.Skin.VERDIGRIS_KEEPER, VolunteerRig.Motion.IDLE, 0f, 1);
assertEquals(VolunteerRig.Joint.values().length, brass.jointCount());
for (VolunteerRig.Joint joint : VolunteerRig.Joint.values()) {
assertEquals(brass.x(joint), verdigris.x(joint), 0f);
assertEquals(brass.y(joint), verdigris.y(joint), 0f);
assertTrue(brass.x(joint) >= 0f && brass.x(joint) <= 1f);
assertTrue(brass.y(joint) >= 0f && brass.y(joint) <= 1f);
}
assertNotEquals(brass.palette().shell(), verdigris.palette().shell());
assertNotEquals(brass.palette().cloth(), verdigris.palette().cloth());
}
@Test
public void motionChangesLimbsWithoutChangingCollisionOrRootContract() {
VolunteerRig.Pose idle = VolunteerRig.pose(
VolunteerRig.Skin.EMBER_COURIER, VolunteerRig.Motion.IDLE, 0f, 1);
VolunteerRig.Pose run = VolunteerRig.pose(
VolunteerRig.Skin.EMBER_COURIER, VolunteerRig.Motion.RUN, 0.25f, 1);
VolunteerRig.Pose mirrored = VolunteerRig.pose(
VolunteerRig.Skin.EMBER_COURIER, VolunteerRig.Motion.RUN, 0.25f, -1);
assertEquals(62f, idle.collisionWidth(), 0f);
assertEquals(100f, idle.collisionHeight(), 0f);
assertEquals(idle.x(VolunteerRig.Joint.ROOT), run.x(VolunteerRig.Joint.ROOT), 0f);
assertNotEquals(idle.x(VolunteerRig.Joint.LEFT_HAND),
run.x(VolunteerRig.Joint.LEFT_HAND), 0.001f);
assertEquals(1f - run.x(VolunteerRig.Joint.LEFT_HAND),
mirrored.x(VolunteerRig.Joint.LEFT_HAND), 0.001f);
}
@Test
public void gameplayStatesHaveNonColorVisualSignals() {
VolunteerRig.Pose dead = VolunteerRig.pose(
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.DEAD, 0f, 1);
VolunteerRig.Pose frozen = VolunteerRig.pose(
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.FROZEN, 0f, 1);
VolunteerRig.Pose conductive = VolunteerRig.pose(
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.CONDUCTIVE, 0f, 1);
VolunteerRig.Pose pinned = VolunteerRig.pose(
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.PINNED, 0f, 1);
VolunteerRig.Pose respawn = VolunteerRig.pose(
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.RESPAWN, 0.5f, 1);
assertFalse(dead.visorLit());
assertTrue(frozen.frozenShell());
assertTrue(conductive.conductiveArcs());
assertTrue(pinned.anchorBolt());
assertEquals(0.5f, respawn.assemblyProgress(), 0f);
}
@Test
public void gameplayMotionIsDerivedFromGroundVelocityAndBodyState() {
assertEquals(VolunteerRig.Motion.IDLE,
VolunteerRig.aliveMotion(true, 0f, 0f));
assertEquals(VolunteerRig.Motion.RUN,
VolunteerRig.aliveMotion(true, 370f, 0f));
assertEquals(VolunteerRig.Motion.JUMP,
VolunteerRig.aliveMotion(false, 120f, -400f));
assertEquals(VolunteerRig.Motion.FALL,
VolunteerRig.aliveMotion(false, 120f, 200f));
assertEquals(VolunteerRig.Motion.FROZEN,
VolunteerRig.bodyMotion(true, true, true));
assertEquals(VolunteerRig.Motion.CONDUCTIVE,
VolunteerRig.bodyMotion(false, true, true));
assertEquals(VolunteerRig.Motion.PINNED,
VolunteerRig.bodyMotion(false, false, true));
assertEquals(VolunteerRig.Motion.DEAD,
VolunteerRig.bodyMotion(false, false, false));
}
}
+5
View File
@@ -154,6 +154,11 @@ baseline-задачи также имели явного владельца по
- progress восстанавливается после Activity/process recreation и обновления приложения;
- `goon-game-milestone-vslice` принят вместе с performance и human-playtest gates.
Статус на 19 августа 2026: инженерный candidate `0.8.0-stage5-vslice` готов.
Art/audio/camera/save/determinism/performance реализованы; 72 JVM-теста и единый
Android 16 run `35/35` (`493.521 s`) GREEN. Milestone намеренно остаётся открытым
до 20 независимых no-explanation сессий по протоколу `STAGE5_HUMAN_PLAYTEST.md`.
### Этап 6. Production content — P1, несколько итераций
Задачи:
+68
View File
@@ -0,0 +1,68 @@
# Stage 5 Art Bible — Clockwork Catacombs
Status: approved production direction for the Android vertical slice.
## Identity
The game is an original dark-comedy clockwork fantasy. Disposable volunteers are faceless brass-and-cloth constructs exploring an ancient teal-lit reliquary. Their destruction is readable and absurd, never graphic. The visual language must communicate puzzle state before decoration.
This direction deliberately does not reproduce the title, knights, silhouettes, heraldry, costumes, props, level layouts, UI, animation, copy, audio, or other protected expression of *Life Goes On: Done to Death*. The shared idea is limited to the independently implemented sacrifice-puzzle mechanic.
## Character rig and skins
Every volunteer uses one normalized articulated rig so skins can change without changing collision or gameplay:
- anchors: root, hips, chest, head, left/right shoulder, hand, knee, and foot;
- collision remains the existing 62 x 100 gameplay rectangle and is never derived from artwork;
- rendering may mirror the rig for facing, but anchor names and normalized bounds stay stable;
- a skin is a palette and trim set, not a different body shape;
- Stage 5 ships three original palettes: Brass Scout, Verdigris Keeper, and Ember Courier.
The head is a round riveted lantern-mask with a single horizontal visor. The torso is a narrow boiler shell, limbs are visible rods joined by circular pivots, and a short asymmetric cloth tab supplies motion. No medieval helmet, knight crest, human face, blood, or gore is used.
## Required state reads
The same rig must make these states recognizable without relying on color alone:
- alive idle: balanced stance, lit visor, slow boiler pulse;
- run: alternating limb arcs and trailing cloth;
- jump/fall: compressed or extended silhouette according to vertical velocity;
- dead body: collapsed rotated rig with an unlit visor;
- pinned body: rigid horizontal brace with a visible anchor bolt;
- frozen body: squared ice shell plus intact rig silhouette and frost cracks;
- conductive body: zig-zag arc ring plus bright joint sparks;
- respawn: concentric assembly rings and a rising segmented silhouette.
High-contrast mode must preserve outline, state icon, and value separation. Animation is decorative only: collision, switch contact, and goal detection use the existing physics objects.
## Environment and hazards
- platforms: ink-dark masonry faces with brass top rails and readable collision edges;
- pressure plates and relays: one clear interaction glyph and a mechanical travel state;
- doors: heavy portcullis frame; open/closed remains distinguishable by silhouette;
- spikes: triangular teeth with pale edge highlights;
- saws: toothed brass wheels with a dark hub and rotation trail;
- freezer: cyan condenser frame and crystalline field;
- electricity: yellow-white broken arcs with a distinct relay line;
- pits: near-black depth with sparse teal motes, never a flat platform-colored void;
- goal: an original suspended chronolume relic, not a crown or chalice.
## UI and camera
HUD panels use translucent ink surfaces, warm brass titles, pale body copy, and minimum contrast already enforced by accessibility settings. Controls remain circular and stay inside the safe viewport. Camera motion may add focus and short impact shake, but must not conceal hazards or move collision geometry. Reduced-motion mode disables shake and decorative bobbing while retaining state changes.
## Animation and performance budgets
- procedural rig animation is evaluated from immutable gameplay state and elapsed time;
- at most one character rig per live volunteer and one collapsed rig per retained body;
- no per-frame bitmap allocation, shader construction, collection growth, or text measurement;
- particles are capped, short-lived, and may be dropped before gameplay or UI readability;
- visual updates target 60 FPS and remain functional at the existing fixed physics cap.
## Acceptance checklist
- All ten levels use the same authored visual language.
- Alive, dead, pinned, frozen, conductive, and respawning states are recognizable in grayscale screenshots.
- At least three palette-only skins use the same normalized skeleton.
- Art does not change geometry, timings, planned death counts, or bot routes.
- No copied or traced commercial assets, audio, text, characters, or level dressings are present.
+38
View File
@@ -0,0 +1,38 @@
# Stage 5 external playtest protocol
This is the only remaining Stage 5 acceptance gate. Automated bots, developers,
and self-testing cannot satisfy it.
## Setup
- Use the immutable `0.8.0-stage5-vslice-debug` APK and record its SHA-256.
- Recruit at least two independent participants for every level (20 sessions).
- Do not explain controls, mechanics, level intent, or the meaning of death.
- A participant may read only the text shown by the game.
- Record screen/video when consent and local policy allow it; do not collect PII.
## Per-session record
| Field | Required value |
|---|---|
| Session ID | anonymous local identifier |
| Level | 1–10 |
| Tester cohort | new / familiar with platformers |
| Completed | yes/no |
| Time | seconds |
| Volunteers | deaths retained by the result screen |
| Restarts | count |
| First wrong assumption | short observation |
| Exit/softlock point | coordinates or visible object |
| Help requested | exact question; developer still gives no answer |
| Severity | blocker / major / minor / observation |
| Disposition | fix / accepted / follow-up Bead |
## Acceptance
- 20/20 required sessions are recorded.
- No blocker softlock is reproduced.
- Every major observation has a fix or explicit product decision.
- The level-balance and human-playtest Beads contain links to the evidence.
- Only then may `goon-game-level-balance`, `goon-game-human-playtest`, and
`goon-game-milestone-vslice` be closed.
+33
View File
@@ -0,0 +1,33 @@
# Stage 5 performance and determinism contract
Stage 5 targets 60 FPS and requires a steady-state 30 FPS hard floor on a
hardware-accelerated target. Cold launch/orientation time is measured separately
from gameplay cadence.
| Metric | Release ceiling |
|---|---:|
| p95 frame interval | 34 ms |
| p95 simulation update | 12 ms |
| p95 Canvas draw | 20 ms |
| retained bodies | 16 |
| collision checks per sampled frame | 2,500 |
| estimated draw primitives | 2,200 |
| used Java heap | 128 MiB |
`PerformanceMonitor` keeps an allocation-free rolling window of 240 samples.
`StageFivePerformanceTest` allows five seconds for Activity rotation and shader
warm-up, resets the monitor, then measures ten uninterrupted seconds of level 10.
The final Android 16 run on a 1080×2400, 60 Hz AVD using the Apple M3 Pro host GPU
passed every ceiling.
The software-only SwiftShader AVD is retained for deterministic gameplay tests,
but is not presented as a physical-device performance result. Its measured
steady-state p95 frame interval was 41.61 ms against the 34 ms gate. This known
environment result is intentionally recorded rather than hidden by weakening the
release budget.
Physics uses `PhysicsConfig` version 1 and a 1/60 s deterministic automation
step. `StageFiveDeterminismTest` records all ten state-driven routes and replays
each exact command tape three times. Terminal/checkpoint hashes and expected
death/body counts must match, and each complete ten-level replay pass must finish
under five minutes.
+57
View File
@@ -0,0 +1,57 @@
# Stage 5 Vertical Slice QA report
Date: 19 August 2026
Candidate: `0.8.0-stage5-vslice-debug`, versionCode 9
Package: `com.projectsacrifice.game.debug`
APK SHA-256: `8bb970c7385a48d8002907382067a5f8f87554a33ad7aaace9aa593194c8fed5`
## Delivered engineering scope
- Original Clockwork Catacombs art bible and a normalized articulated volunteer
rig with three skins and run/jump/fall/death/frozen/conductive/pinned/respawn states.
- Procedural music, effects, and voice variants with independent persisted Music,
Effects, and Voice volume controls; unavailable audio degrades to silence.
- Bounded horizontal/vertical camera damping, mechanism focus, impact shake, and
a persisted reduced-motion option; camera never changes physics coordinates.
- Save version 2, safe v1 progress migration, full active-puzzle Bundle snapshot,
and visible-paused restore after Activity recreation.
- Physics version 1, deterministic input tapes, terminal/checkpoint signatures,
and explicit runtime performance ceilings.
## Automated evidence
- Clean `testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest`:
GREEN; 72 JVM tests in 23 classes; all 10 JSON levels validated.
- Exact final Android 16 unfiltered run: `OK (35 tests)` in `493.521 s`.
- The run includes all 10 production-input solutions, level 8 recovery, level 10
delayed decisions/body stability, restart, lifecycle, save migration/recreation,
RU/EN, controls, onboarding/results, audio persistence, performance, and Stage 3 regressions.
- Determinism gate inside that run: 10 recorded routes replayed three sequential
times (30 completions) with matching signatures and under-five-minute pass guards.
- Host-GPU steady-state performance gate: GREEN against every published ceiling.
- Software SwiftShader observation: p95 frame interval 41.61 ms, above the 34 ms
target-device gate; documented as a non-target renderer limitation.
- Installed APK cold-launched successfully; post-run crash/ANR buffer contained no
Project Sacrifice fatal exception or ANR.
## Package verification
- `aapt`: package/version/minSdk 23/targetSdk 34 confirmed.
- `unzip -t`: valid APK/ZIP, no compressed-data errors.
- `apksigner`: v1 and v2 signatures valid; one Android Debug signer.
- This is an installable debug testing candidate, not a store-signed release.
## TDD evidence highlights
- Volunteer rig, audio variants/settings, camera, save codec, physics config,
deterministic tape, and performance monitor began with missing-contract RED tests.
- Session restore first exposed invalid wall-clock setup and was replaced by an
exact fixed-step saved-state assertion before going GREEN.
- Performance first failed on SwiftShader (`41.61 > 34 ms`); the final target gate
was run on hardware-accelerated Android after separating cold rotation warm-up.
## Gate disposition
The Stage 5 engineering release candidate is complete and reproducible. Formal
Stage 5 milestone acceptance remains pending only on the external 20-session
no-explanation playtest in `STAGE5_HUMAN_PLAYTEST.md`; no human evidence was fabricated.
+3 -3
View File
@@ -35,8 +35,8 @@
## Незавершённые сквозные gates
- `goon-game-regression-10` имеет `stage-1`, но primary Stage 5: runner создан,
однако критерий трёх полных прогонов быстрее пяти минут ещё не доказан.
- `goon-game-regression-10` имеет `stage-1`, но primary Stage 5: закрыт после
трёх детерминированных полных replay-проходов 10/10 с under-five-minute guards.
- `goon-game-level-balance` имеет `stage-2`, но primary Stage 5: инженерные
исправления сделаны, а внешний comprehension/playtest gate остаётся открытым.
- `goon-game-human-playtest` относится к Stage 5 и не может быть закрыт ботами.
@@ -64,7 +64,7 @@ Snapshot на 19 августа 2026 года:
| 2 | 5 | 0 |
| 3 | 5 | 0 |
| 4 | 4 | 0 |
| 5 | 15 | 15 |
| 5 | 15 | 3 |
| 6 | 18 | 18 |
| 7 | 9 | 9 |
| 8 | 1 | 1 |