Compare commits
35
Commits
e512342551
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0943caa3cc | ||
|
|
dcb7c2d497 | ||
|
|
3527ee5bd6 | ||
|
|
cde886ca96 | ||
|
|
b840684ef3 | ||
|
|
093fb63ca6 | ||
|
|
5051f316fe | ||
|
|
2ddac2775f | ||
|
|
1e9520dc14 | ||
|
|
55b90fb0a5 | ||
|
|
fa68e2e04a | ||
|
|
9d792bdee4 | ||
|
|
a8d72024f2 | ||
|
|
f06467fcfa | ||
|
|
f07d1cf830 | ||
|
|
da3777b4cc | ||
|
|
650bfb3533 | ||
|
|
88a1ba9b34 | ||
|
|
de3b9485c1 | ||
|
|
21775542d5 | ||
|
|
dcb16ef4d0 | ||
|
|
00323d7916 | ||
|
|
44067abde3 | ||
|
|
80822b74b1 | ||
|
|
3ad959dde0 | ||
|
|
cf845d5d9d | ||
|
|
a87e6cd62b | ||
|
|
de0f8b891f | ||
|
|
9e418df184 | ||
|
|
6790a84d86 | ||
|
|
70106e29d8 | ||
|
|
3ed6d9ebab | ||
|
|
a8f5ddc0a9 | ||
|
|
1ebad500f3 | ||
|
|
4c161d4172 |
@@ -17,3 +17,33 @@
|
||||
{"id":"int-b5f69bfd","kind":"field_change","created_at":"2026-08-17T21:41:29.38583Z","actor":"E.Gavrilov","issue_id":"goon-game-milestone-vslice","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
||||
{"id":"int-78cceca7","kind":"field_change","created_at":"2026-08-17T22:09:00.917089Z","actor":"E.Gavrilov","issue_id":"goon-game-bot-framework","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Декларативный Scenario runner, именованные шаги, per-step timeout и observable diagnostics реализованы; 11 JVM-тестов, lint, instrumentation compile и connected level 5 GREEN на Android 16."}}
|
||||
{"id":"int-70d3a443","kind":"field_change","created_at":"2026-08-17T22:09:09.35446Z","actor":"E.Gavrilov","issue_id":"goon-game-solution-graphs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Документированы 10 source-backed solution graphs с механиками, смертями, checkpoints и рисками; только уровень 5 честно отмечен доказанным connected-тестом."}}
|
||||
{"id":"int-f061141a","kind":"field_change","created_at":"2026-08-18T01:30:33.988847Z","actor":"E.Gavrilov","issue_id":"goon-game-bots-1-4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Уровни 1–4 проходят production-input ботами по 3 раза подряд; финальный integrated Android 16 matrix 14/14 green."}}
|
||||
{"id":"int-ab10aee3","kind":"field_change","created_at":"2026-08-18T01:30:37.522248Z","actor":"E.Gavrilov","issue_id":"goon-game-level6-freezer-overlap","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"FREEZER больше не убивает игрока на frozen stepping stone; floor lethality сохранена; уровень 6 проходит 3/3."}}
|
||||
{"id":"int-129af3b6","kind":"field_change","created_at":"2026-08-18T01:30:42.004043Z","actor":"E.Gavrilov","issue_id":"goon-game-level8-softlock","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Expiry справа от PIT воспроизводился; recovery relay и баланс таймера проверены intended 3/3 и recovery 3/3."}}
|
||||
{"id":"int-2dabc667","kind":"field_change","created_at":"2026-08-18T01:30:46.06691Z","actor":"E.Gavrilov","issue_id":"goon-game-bots-6-10","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Уровни 6–10 проходят intended routes по 3 раза; уровни 8 и 10 дополнительно проходят 96ms stress checks."}}
|
||||
{"id":"int-2cbd2a1d","kind":"field_change","created_at":"2026-08-18T10:41:46.8206Z","actor":"E.Gavrilov","issue_id":"goon-game-level10-sliding-corpse","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Acceptance met: delivered body remains on lift plate, signal stays active, normal 3x and delayed-input level 10 routes pass."}}
|
||||
{"id":"int-a046aa8d","kind":"field_change","created_at":"2026-08-18T10:41:48.065633Z","actor":"E.Gavrilov","issue_id":"goon-game-level-route-risks","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All intended risky routes are reproduced by connected bots; possible L2 alternative is explicitly accepted and documented as non-blocking."}}
|
||||
{"id":"int-33f283fa","kind":"field_change","created_at":"2026-08-18T11:43:04.54852Z","actor":"E.Gavrilov","issue_id":"goon-game-controls-settings","extra":{"field":"status","new_value":"closed","old_value":"in_progress","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."}}
|
||||
{"id":"int-002dd441","kind":"field_change","created_at":"2026-08-18T16:26:41.026722Z","actor":"E.Gavrilov","issue_id":"goon-game-accessibility","extra":{"field":"status","new_value":"closed","old_value":"in_progress","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."}}
|
||||
{"id":"int-96bf6e31","kind":"field_change","created_at":"2026-08-18T16:26:41.415975Z","actor":"E.Gavrilov","issue_id":"goon-game-stage3-onboarding-results","extra":{"field":"status","new_value":"closed","old_value":"in_progress","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."}}
|
||||
{"id":"int-d07e319c","kind":"field_change","created_at":"2026-08-18T16:26:41.837124Z","actor":"E.Gavrilov","issue_id":"goon-game-stage3-localization","extra":{"field":"status","new_value":"closed","old_value":"in_progress","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."}}
|
||||
{"id":"int-b7849cda","kind":"field_change","created_at":"2026-08-18T16:26:42.858032Z","actor":"E.Gavrilov","issue_id":"goon-game-menus-localization","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Persistent RU/EN, onboarding and full result flow integrated and verified by final 30/30 gate. External human comprehension playtest remains tracked separately."}}
|
||||
{"id":"int-1db644c7","kind":"field_change","created_at":"2026-08-18T19:01:03.897581Z","actor":"E.Gavrilov","issue_id":"goon-game-stage4-level-json","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"All 10 levels migrated to validated versioned JSON; Java geometry constants removed; runtime and regression matrix GREEN."}}
|
||||
{"id":"int-3cc18cb9","kind":"field_change","created_at":"2026-08-18T19:01:21.107222Z","actor":"E.Gavrilov","issue_id":"goon-game-stage4-validator-tools","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared build validator and deterministic desktop preview/export implemented, documented, and verified."}}
|
||||
{"id":"int-bb998516","kind":"field_change","created_at":"2026-08-18T19:01:34.533286Z","actor":"E.Gavrilov","issue_id":"goon-game-stage4-bot-traces","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Deterministic versioned traces and diagnostic IDs implemented for all bot runner families."}}
|
||||
{"id":"int-e2592271","kind":"field_change","created_at":"2026-08-18T19:01:46.730283Z","actor":"E.Gavrilov","issue_id":"goon-game-stage4-content-architecture","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stage 4 engineering acceptance complete; JSON pipeline, validator/exporter, traces, full regression, docs and APK smoke are GREEN."}}
|
||||
{"id":"int-53e6e7be","kind":"field_change","created_at":"2026-08-18T21:41:05.01858Z","actor":"E.Gavrilov","issue_id":"goon-game-art-bible","extra":{"field":"status","new_value":"closed","old_value":"in_progress","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."}}
|
||||
{"id":"int-bcd19fff","kind":"field_change","created_at":"2026-08-18T23:01:31.018301Z","actor":"E.Gavrilov","issue_id":"goon-game-art-vslice","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Original articulated visual pass is implemented for all ten levels and verified in the final candidate."}}
|
||||
{"id":"int-82e7e6ee","kind":"field_change","created_at":"2026-08-18T23:01:38.596112Z","actor":"E.Gavrilov","issue_id":"goon-game-audio-vslice","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All ten levels use the completed procedural audio pass with independent persistent controls."}}
|
||||
{"id":"int-c9f92e2a","kind":"field_change","created_at":"2026-08-18T23:01:45.232976Z","actor":"E.Gavrilov","issue_id":"goon-game-camera-polish","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Production camera behavior and reduced-motion acceptance are implemented and regression-tested."}}
|
||||
{"id":"int-ed8fd41c","kind":"field_change","created_at":"2026-08-18T23:01:54.885977Z","actor":"E.Gavrilov","issue_id":"goon-game-save-versioning","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Legacy progress migrates without loss and supported active sessions restore safely."}}
|
||||
{"id":"int-8a44cbd0","kind":"field_change","created_at":"2026-08-18T23:02:01.438717Z","actor":"E.Gavrilov","issue_id":"goon-game-android-lifecycle","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Supported lifecycle and Activity/process-like recreation state restore are proven for the Vertical Slice."}}
|
||||
{"id":"int-3048ec9b","kind":"field_change","created_at":"2026-08-18T23:02:08.545046Z","actor":"E.Gavrilov","issue_id":"goon-game-physics-determinism","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Three identical replays for every level produce matching states and outcomes."}}
|
||||
{"id":"int-45949361","kind":"field_change","created_at":"2026-08-18T23:02:15.263158Z","actor":"E.Gavrilov","issue_id":"goon-game-perf-budgets","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Reproducible target-device performance and complexity budgets are implemented and pass."}}
|
||||
{"id":"int-80e71d5b","kind":"field_change","created_at":"2026-08-18T23:02:22.210271Z","actor":"E.Gavrilov","issue_id":"goon-game-regression-10","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Three full 10/10 replay passes satisfy the under-five-minute and reproducibility acceptance."}}
|
||||
{"id":"int-b172f9fb","kind":"field_change","created_at":"2026-08-18T23:02:55.515622Z","actor":"E.Gavrilov","issue_id":"goon-game-e03","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Physics foundation and deterministic replay acceptance are complete."}}
|
||||
{"id":"int-755dedbb","kind":"field_change","created_at":"2026-08-18T23:03:02.65773Z","actor":"E.Gavrilov","issue_id":"goon-game-e06","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Camera foundation and production polish acceptance are complete."}}
|
||||
{"id":"int-1e72a85e","kind":"field_change","created_at":"2026-08-18T23:03:09.06188Z","actor":"E.Gavrilov","issue_id":"goon-game-e10","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Vertical Slice has a complete non-repeating audio pass."}}
|
||||
{"id":"int-a751b7bc","kind":"field_change","created_at":"2026-08-18T23:18:29.972741Z","actor":"E.Gavrilov","issue_id":"goon-game-level-balance","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"External comprehension gate accepted by direct product-owner confirmation; engineering and bot balance gates were already green."}}
|
||||
{"id":"int-0aeb7aff","kind":"field_change","created_at":"2026-08-18T23:18:31.228234Z","actor":"E.Gavrilov","issue_id":"goon-game-human-playtest","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"External playtest accepted by direct product-owner confirmation; evidence limitations documented."}}
|
||||
{"id":"int-9c9b8a6e","kind":"field_change","created_at":"2026-08-18T23:18:32.463438Z","actor":"E.Gavrilov","issue_id":"goon-game-milestone-vslice","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Vertical Slice accepted: 10/10 automated, engineering gates green, external testing confirmed complete by product owner."}}
|
||||
|
||||
+78
-70
@@ -1,70 +1,78 @@
|
||||
{"_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":"open","priority":0,"issue_type":"bug","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:40Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T22:09:40Z","labels":["bug","current","level8","softlock"],"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-17T21:44:10Z","started_at":"2026-08-17T21:41:29Z","labels":["current","gate","milestone"],"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-17T21:44:01Z","closed_at":"2026-08-17T21:41:27Z","close_reason":"Exit criteria прототипа подтверждены 10 greybox-уровнями.","labels":["closed","milestone"],"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-17T21:40:54Z","labels":["qa","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 минут каждый.","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-17T21:40:51Z","labels":["current","gate","qa"],"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":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:49Z","labels":["bot","current","qa"],"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":"{}"}],"dependency_count":1,"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":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:47Z","labels":["bot","current","qa"],"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-17T22:09:01Z","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.","labels":["bot","current","qa"],"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-17T21:41:25Z","closed_at":"2026-08-17T21:41:25Z","close_reason":"Android acceptance-тест уровня 5 проходит.","labels":["baseline","bot","closed"],"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-17T21:41:24Z","closed_at":"2026-08-17T21:41:24Z","close_reason":"8 unit-тестов и lintDebug прошли.","labels":["baseline","closed","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-17T22:09:20Z","started_at":"2026-08-17T21:52:46Z","labels":["android","current"],"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-17T21:41:23Z","closed_at":"2026-08-17T21:41:23Z","close_reason":"APK versionCode 3 собран, подписан, установлен и запущен.","labels":["baseline","closed"],"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 уровней проходят три раза подряд и понятны внешнему тестировщику.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:56Z","labels":["balance","current","levels"],"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-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":4,"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-17T22:09:09Z","started_at":"2026-08-17T21:52:46Z","closed_at":"2026-08-17T22:09:09Z","close_reason":"Документированы 10 source-backed solution graphs с механиками, смертями, checkpoints и рисками; только уровень 5 честно отмечен доказанным connected-тестом.","labels":["current","design","levels"],"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-17T21:41:19Z","closed_at":"2026-08-17T21:41:19Z","close_reason":"Созданы 10 последовательных greybox-уровней.","labels":["baseline","closed","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-17T21:39:33Z","labels":["current","physics"],"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-17T21:41:15Z","closed_at":"2026-08-17T21:41:15Z","close_reason":"Базовая физика присутствует; production determinism остаётся отдельной задачей.","labels":["baseline","closed"],"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-17T21:41:14Z","closed_at":"2026-08-17T21:41:14Z","close_reason":"Реализовано и проверено ботом уровня 5.","labels":["baseline","closed"],"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-17T21:44:10Z","started_at":"2026-08-17T21:41:29Z","labels":["epic","milestone","release"],"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-17T21:44:09Z","started_at":"2026-08-17T21:41:28Z","labels":["current","epic","qa"],"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-17T21:39:08Z","labels":["android","epic"],"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-17T21:38:57Z","labels":["content","epic","levels"],"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-17T21:38:49Z","labels":["epic","physics"],"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-17T21:38:47Z","labels":["corpse","death","epic"],"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-17T21:44:08Z","started_at":"2026-08-17T21:41:28Z","labels":["gdd","master","production","tdd"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level-route-risks","title":"Доказать спорные маршруты уровней 2, 4 и 10","description":"Проверить возможный sequence break уровня 2, узкий lift→gallery прыжок уровня 4 и optional FREEZER/body delivery/lift→ledge риски уровня 10.","acceptance_criteria":"Для каждого риска есть воспроизводимый connected bot test; intended route проходит 3 раза, а допустимые альтернативы явно зафиксированы или геометрия исправлена.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T22:09:58Z","labels":["balance","bot","current","levels"],"dependencies":[{"issue_id":"goon-game-level-route-risks","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:10:06Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_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-17T21:40:56Z","labels":["android","qa"],"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-17T21:40:38Z","labels":["analytics","android"],"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-17T21:40:34Z","labels":["corpse","performance"],"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-17T21:40:32Z","labels":["performance","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-17T21:40:30Z","labels":["android","release"],"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-17T21:40:28Z","labels":["android","qa"],"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-17T21:40:19Z","labels":["art","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-17T21:40:17Z","labels":["art","design","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-17T21:40:15Z","labels":["audio","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-17T21:40:11Z","labels":["save","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-17T21:41:22Z","closed_at":"2026-08-17T21:41:22Z","close_reason":"Локальный прогресс и лучшие результаты сохраняются.","labels":["baseline","closed"],"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":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:06Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:06Z","labels":["accessibility","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":"open","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-17T21:40:04Z","labels":["localization","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 и планшете.","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:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:02Z","labels":["current","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-17T21:41:21Z","closed_at":"2026-08-17T21:41:21Z","close_reason":"Базовый HUD и multitouch controls реализованы.","labels":["baseline","closed"],"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-17T21:39:50Z","labels":["camera","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-17T21:41:18Z","closed_at":"2026-08-17T21:41:18Z","close_reason":"Базовое сглаженное слежение реализовано.","labels":["baseline","closed"],"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-17T21:39:41Z","labels":["architecture","puzzle"],"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-17T21:41:17Z","closed_at":"2026-08-17T21:41:17Z","close_reason":"Базовые кнопки, двери и платформы реализованы.","labels":["baseline","closed"],"dependencies":[{"issue_id":"goon-game-puzzle-vslice","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:11Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-hazards-vslice","title":"Опасности vertical slice","description":"Реализованы spikes, saw, pit, freeze и electricity.","acceptance_criteria":"Опасности присутствуют в каталоге 10 уровней и не ломают restart.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:35Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:16Z","closed_at":"2026-08-17T21:41:16Z","close_reason":"Базовый набор опасностей присутствует в 10 уровнях.","labels":["baseline","closed"],"dependencies":[{"issue_id":"goon-game-hazards-vslice","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-corpse-manager","title":"Выделить полноценный CorpseManager","description":"Убрать управление телами из монолитного GameView; добавить register/remove/transform/reset, лимит и диагностические счётчики.","acceptance_criteria":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:27Z","labels":["architecture","corpse"],"dependencies":[{"issue_id":"goon-game-corpse-manager","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:04Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-character-states","title":"Завершить state machine персонажа","description":"Добавить явные Interact, Stunned, Turn, Land и animation-state переходы; вынести coyote/input buffer в конфиг.","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:20Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:20Z","labels":["character","future"],"dependencies":[{"issue_id":"goon-game-character-states","depends_on_id":"goon-game-e01","type":"parent-child","created_at":"2026-08-18T00:43:02Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-character-basic","title":"Базовый character controller","description":"Движение, остановка, разворот, прыжок, падение и приземление в текущем Canvas runtime.","acceptance_criteria":"Функции присутствуют в APK и покрыты smoke-тестом.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:18Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:12Z","closed_at":"2026-08-17T21:41:12Z","close_reason":"Реализовано в текущем vertical slice.","labels":["baseline","closed"],"dependencies":[{"issue_id":"goon-game-character-basic","depends_on_id":"goon-game-e01","type":"parent-child","created_at":"2026-08-18T00:43:01Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_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-17T21:39:10Z","labels":["epic","performance"],"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-17T21:39:01Z","labels":["epic","progression","save"],"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-17T21:38:59Z","labels":["epic","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-17T21:38:55Z","labels":["camera","epic"],"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-17T21:38:53Z","labels":["epic","puzzle"],"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-17T21:38:51Z","labels":["epic","hazards"],"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-17T21:38:45Z","labels":["character","epic"],"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}
|
||||
{"_type":"issue","id":"goon-game-milestone-alpha","title":"Milestone: Alpha","description":"Вся игра проходима от первого меню до финала; контент существует.","acceptance_criteria":"End-to-end прохождение завершено на release-like build.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:44:02Z","labels":["alpha","milestone"],"dependencies":[{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-crash-reporting","type":"blocks","created_at":"2026-08-18T00:44:28Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:44:27Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:57Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-milestone-production","type":"blocks","created_at":"2026-08-18T00:44:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-milestone-production","title":"Milestone: Production","description":"Массовое производство уровней параллельными потоками после утверждения vertical slice.","acceptance_criteria":"Полный content matrix реализован и прошёл content pipeline.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:44:02Z","labels":["milestone","production"],"dependencies":[{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-content-worlds","type":"blocks","created_at":"2026-08-18T00:44:25Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:56Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:44:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-analytics-contract","title":"Схема gameplay analytics","description":"Версионировать level_start/restart/death/complete/abandon/challenge и heatmap payload.","acceptance_criteria":"События документированы, минимальны и проверены на тестовом backend.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:36Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:36Z","labels":["analytics","production"],"dependencies":[{"issue_id":"goon-game-analytics-contract","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-modular-characters","title":"Модульные персонажи и косметика","description":"Body/helmet/face/weapon/cape/accessory, случайные имена и 30+ косметических элементов.","acceptance_criteria":"Новые жертвы визуально различаются, комбинации не ломают анимации.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2880,"created_at":"2026-08-17T21:40:21Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:21Z","labels":["art","production"],"dependencies":[{"issue_id":"goon-game-modular-characters","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-challenges","title":"Challenges, collectibles и статистика","description":"Реализовать casualty/time/collectible цели и отображение трёх звёзд.","acceptance_criteria":"Расчёт покрыт unit-тестами и сохраняется по LevelID.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:13Z","labels":["production","progression"],"dependencies":[{"issue_id":"goon-game-challenges","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:44:01Z","labels":["future","levels","production"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:43Z","labels":["production","puzzle"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:37Z","labels":["hazards","production"],"dependencies":[{"issue_id":"goon-game-hazards-full","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:10Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:29Z","labels":["corpse","production"],"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-17T21:39:12Z","labels":["analytics","epic"],"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-17T21:39:05Z","labels":["art","epic"],"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-17T21:39:03Z","labels":["audio","epic"],"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-17T21:44:02Z","labels":["milestone","release-candidate"],"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-17T21:44:02Z","labels":["beta","milestone"],"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}
|
||||
{"_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.","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.\n2026-08-19: завершение внешнего тестирования принято по прямому подтверждению владельца продукта; инженерные зависимости закрыты, ограничения доказательств документированы. Stage 5 принят, Stage 6 разблокирован.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","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:18:32Z","started_at":"2026-08-17T21:41:29Z","closed_at":"2026-08-18T23:18:32Z","close_reason":"Vertical Slice accepted: 10/10 automated, engineering gates green, external testing confirmed complete by product owner.","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; наблюдения и решения зафиксированы.","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.\n2026-08-19: владелец продукта подтвердил завершение внешнего тестирования и переход к следующему этапу. Граница доказательств зафиксирована в docs/STAGE5_HUMAN_PLAYTEST_RESULT.md; индивидуальные статистики не выдумываются.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","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:18:31Z","closed_at":"2026-08-18T23:18:31Z","close_reason":"External playtest accepted by direct product-owner confirmation; evidence limitations documented.","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 восстановления.\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.\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.\n2026-08-19: владелец продукта подтвердил завершение внешнего тестирования и распорядился перейти дальше. Подтверждение принято как comprehension gate; сырые формы сессий и количественные метрики не приложены и не реконструируются. Автоматические 10/10 и stability gates остаются GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","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:18:30Z","started_at":"2026-08-18T01:31:03Z","closed_at":"2026-08-18T23:18:30Z","close_reason":"External comprehension gate accepted by direct product-owner confirmation; engineering and bot balance gates were already green.","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 каждого уровня дают одинаковый итог и не расходятся по ключевым состояниям.","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":"Одинаковая последовательность ввода воспроизводимо приводит к одинаковому состоянию головоломки.","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}
|
||||
{"_type":"issue","id":"goon-game-stage4-validator-tools","title":"Добавить build validator и desktop preview/export","description":"Создать запускаемый на desktop инструмент, который валидирует каталог до Android-сборки и экспортирует координаты в читаемый SVG/текстовый отчёт.","design":"Инструмент использует ту же pure-Java загрузку и validator, что runtime, без дублирования правил в Gradle или Python.","acceptance_criteria":"Gradle build отклоняет broken fixture до упаковки APK; CLI валидирует все levels и создаёт deterministic preview/export; команда и формат задокументированы.","notes":"GREEN: preBuild validateLevels, path-specific broken-signal rejection, deterministic 10 SVG + catalog.tsv preview.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:28Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:04Z","started_at":"2026-08-18T19:01:11Z","closed_at":"2026-08-18T19:01:21Z","close_reason":"Shared build validator and deterministic desktop preview/export implemented, documented, and verified.","metadata":{"stages":4,"primary_stage":4},"labels":["primary-stage-4","stage-4","stage4","tooling","validator"],"dependencies":[{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:31Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-level-json","type":"blocks","created_at":"2026-08-18T20:54:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-stage4-level-json","title":"Мигрировать LevelCatalog в versioned JSON","description":"Добавить формат уровня, loader и чистый доменный validator, затем перенести все 10 LevelDefinition из Java-констант в assets JSON вместе с RU/EN title и hint.","design":"Tracer bullet: один JSON проходит codec+validator через публичный catalog loader; затем миграция уровня за уровнем с эквивалентностными контрактами.","acceptance_criteria":"Формат и physics version проверяются; десять JSON загружаются в тот же наблюдаемый каталог; spawn, goal, bounds, уникальность номеров и ссылочная целостность проверены public behavior tests; LevelCatalog больше не содержит геометрию уровней.","notes":"GREEN: 10 ordered format/physics v1 JSON levels, RU/EN copy, shared loader/validator; 54 JVM tests and full Android 30-test matrix passed.","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:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:05Z","started_at":"2026-08-18T17:54:35Z","closed_at":"2026-08-18T19:01:04Z","close_reason":"All 10 levels migrated to validated versioned JSON; Java geometry constants removed; runtime and regression matrix GREEN.","metadata":{"stages":4,"primary_stage":4},"labels":["json","levels","primary-stage-4","stage-4","stage4"],"dependencies":[{"issue_id":"goon-game-stage4-level-json","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:31Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-stage4-content-architecture","title":"Stage 4: контентная архитектура и инструменты","description":"Перевести десять уровней в versioned data-driven формат, добавить build-time validation, desktop preview/export и воспроизводимые bot traces без изменения игровой физики.","design":"Один канонический JSON на уровень; Android loader и build tool применяют одинаковые инварианты; runtime не имеет Java-констант геометрии; изменения выполняются вертикальными RED-GREEN срезами.","acceptance_criteria":"Уровни загружаются из JSON; невалидные геометрия, ссылки и обязательные объекты отклоняются при сборке; desktop tool выдаёт проверяемый preview/export; format/physics versions зафиксированы; все существующие боты остаются GREEN.","notes":"Acceptance: clean 74-task Gradle gate; APK ZIP/badging/v1-v2 signature; clean Android 16 install/cold start/crash buffer; unfiltered OK (30 tests) in 1096.448s.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:26Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:05Z","started_at":"2026-08-18T17:54:34Z","closed_at":"2026-08-18T19:01:47Z","close_reason":"Stage 4 engineering acceptance complete; JSON pipeline, validator/exporter, traces, full regression, docs and APK smoke are GREEN.","metadata":{"stages":4,"primary_stage":4},"labels":["content","levels","primary-stage-4","stage-4","stage4","tooling"],"dependencies":[{"issue_id":"goon-game-stage4-content-architecture","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T20:54:30Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-stage3-localization","title":"Persistent RU/EN локализация","description":"Локализовать player-facing Canvas UI, названия и hints уровней на русский и английский с переключателем языка.","acceptance_criteria":"Все player-facing строки имеют RU/EN варианты; выбранный язык сохраняется после Activity recreation; смена языка не сбрасывает progress или control settings.","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-18T13:09:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:59Z","started_at":"2026-08-18T13:09:56Z","closed_at":"2026-08-18T16:26:42Z","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":["localization","primary-stage-3","stage-3","stage3","ui"],"dependencies":[{"issue_id":"goon-game-stage3-localization","depends_on_id":"goon-game-menus-localization","type":"parent-child","created_at":"2026-08-18T16:09:56Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-stage3-onboarding-results","title":"Onboarding и полноценный result flow","description":"Добавить короткое пропускаемое обучение без блокирующих длинных окон и result screen с текущими/лучшими временем и жертвами, replay/next/levels.","acceptance_criteria":"Первый запуск объясняет движение, прыжок и полезную смерть; обучение можно пропустить и оно не повторяется после завершения; result показывает current/best и все переходы работают без сброса прогресса.","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-18T13:09:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:00Z","started_at":"2026-08-18T13:09:55Z","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":["onboarding","primary-stage-3","stage-3","stage3","ui"],"dependencies":[{"issue_id":"goon-game-stage3-onboarding-results","depends_on_id":"goon-game-menus-localization","type":"parent-child","created_at":"2026-08-18T16:09:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level-route-risks","title":"Доказать спорные маршруты уровней 2, 4 и 10","description":"Проверить возможный sequence break уровня 2, узкий lift→gallery прыжок уровня 4 и optional FREEZER/body delivery/lift→ledge риски уровня 10.","acceptance_criteria":"Для каждого риска есть воспроизводимый connected bot test; intended route проходит 3 раза, а допустимые альтернативы явно зафиксированы или геометрия исправлена.","notes":"2026-08-18: exploratory production-input direct-jump attempt on level 2 did not confirm the suspected zero-death sequence break; it timed out at x=549.36 with deaths=2/corpses=2. Intended three-corpse route separately passes 3 times. Sequence-break risk remains unproven and open.\n2026-08-18: L4 lift route remains green 3x. L10 bot now explicitly stands on and jumps from the frozen corpse, then completes normal 3x plus delayed traversal. L2 intended three-corpse route remains green 3x; the unconfirmed zero-death hypothesis is accepted as a non-blocking emergent alternative because targetDeaths measures the intended route rather than enforced uniqueness.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:54Z","closed_at":"2026-08-18T10:41:48Z","close_reason":"All intended risky routes are reproduced by connected bots; possible L2 alternative is explicitly accepted and documented as non-blocking.","metadata":{"stages":2,"primary_stage":2},"labels":["balance","bot","current","levels","primary-stage-2","stage-2"],"dependencies":[{"issue_id":"goon-game-level-route-risks","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:10:06Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_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.","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 готов внешнему тестировщику.","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":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","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}
|
||||
{"_type":"issue","id":"goon-game-hazards-vslice","title":"Опасности vertical slice","description":"Реализованы spikes, saw, pit, freeze и electricity.","acceptance_criteria":"Опасности присутствуют в каталоге 10 уровней и не ломают restart.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:35Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:16Z","close_reason":"Базовый набор опасностей присутствует в 10 уровнях.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-hazards-vslice","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-corpse-manager","title":"Выделить полноценный CorpseManager","description":"Убрать управление телами из монолитного GameView; добавить register/remove/transform/reset, лимит и диагностические счётчики.","acceptance_criteria":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:28Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","corpse","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-manager","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:04Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-character-states","title":"Завершить state machine персонажа","description":"Добавить явные Interact, Stunned, Turn, Land и animation-state переходы; вынести coyote/input buffer в конфиг.","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:20Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:28Z","metadata":{"stages":6,"primary_stage":6},"labels":["character","future","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-character-states","depends_on_id":"goon-game-e01","type":"parent-child","created_at":"2026-08-18T00:43:02Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-character-basic","title":"Базовый character controller","description":"Движение, остановка, разворот, прыжок, падение и приземление в текущем Canvas runtime.","acceptance_criteria":"Функции присутствуют в APK и покрыты smoke-тестом.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:18Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:12Z","close_reason":"Реализовано в текущем vertical slice.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-character-basic","depends_on_id":"goon-game-e01","type":"parent-child","created_at":"2026-08-18T00:43:01Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_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":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","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}
|
||||
{"_type":"issue","id":"goon-game-milestone-alpha","title":"Milestone: Alpha","description":"Вся игра проходима от первого меню до финала; контент существует.","acceptance_criteria":"End-to-end прохождение завершено на release-like build.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:39Z","metadata":{"stages":8,"primary_stage":8},"labels":["alpha","milestone","primary-stage-8","stage-8"],"dependencies":[{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-crash-reporting","type":"blocks","created_at":"2026-08-18T00:44:28Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:44:27Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:57Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-milestone-production","type":"blocks","created_at":"2026-08-18T00:44:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-milestone-production","title":"Milestone: Production","description":"Массовое производство уровней параллельными потоками после утверждения vertical slice.","acceptance_criteria":"Полный content matrix реализован и прошёл content pipeline.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["milestone","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-content-worlds","type":"blocks","created_at":"2026-08-18T00:44:25Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:56Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:44:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-analytics-contract","title":"Схема gameplay analytics","description":"Версионировать level_start/restart/death/complete/abandon/challenge и heatmap payload.","acceptance_criteria":"События документированы, минимальны и проверены на тестовом backend.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:36Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:35Z","metadata":{"stages":7,"primary_stage":7},"labels":["analytics","primary-stage-7","production","stage-7"],"dependencies":[{"issue_id":"goon-game-analytics-contract","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-modular-characters","title":"Модульные персонажи и косметика","description":"Body/helmet/face/weapon/cape/accessory, случайные имена и 30+ косметических элементов.","acceptance_criteria":"Новые жертвы визуально различаются, комбинации не ломают анимации.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2880,"created_at":"2026-08-17T21:40:21Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["art","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-modular-characters","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-challenges","title":"Challenges, collectibles и статистика","description":"Реализовать casualty/time/collectible цели и отображение трёх звёзд.","acceptance_criteria":"Расчёт покрыт unit-тестами и сохраняется по LevelID.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","progression","stage-6"],"dependencies":[{"issue_id":"goon-game-challenges","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["hazards","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-hazards-full","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:10Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e14","title":"EPIC-14 Analytics","description":"События прохождения, позиции смертей и crash reporting без лишних персональных данных. Раздел 47.","acceptance_criteria":"События и ошибки проверены в тестовом канале, схема версионирована.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:12Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:57Z","metadata":{"stages":"7,8,9,10","primary_stage":7},"labels":["analytics","epic","primary-stage-7","stage-10","stage-7","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e14","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:57Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e11","title":"EPIC-11 Art","description":"Самостоятельное художественное направление, модульные персонажи и анимации. Разделы 26–28.","acceptance_criteria":"Визуальный язык утверждён, оригинален и покрывает все игровые состояния.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:44Z","metadata":{"stages":"5,6","primary_stage":6},"labels":["art","epic","primary-stage-6","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e11","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e10","title":"EPIC-10 Audio","description":"Sound design, вариативность реакций и музыкальные loops. Разделы 29–30.","acceptance_criteria":"Vertical slice имеет полный audio pass без раздражающих повторов.","notes":"Stage 5 audio acceptance complete: procedural music/effects/voice variation, lifecycle safety and independent persisted controls; the EPIC child is closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:03Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:09Z","closed_at":"2026-08-18T23:03:09Z","close_reason":"Vertical Slice has a complete non-repeating audio pass.","metadata":{"stages":5,"primary_stage":5},"labels":["audio","epic","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-e10","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:52Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_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}
|
||||
|
||||
+50
-25
@@ -1,36 +1,61 @@
|
||||
# QA report
|
||||
# QA report — Stage 2 playability build
|
||||
|
||||
Date: 2026-08-18
|
||||
|
||||
## Result
|
||||
|
||||
The version-4 debug APK passed the final clean build and one integrated Android 16
|
||||
device run. All ten intended level routes completed three consecutive times through
|
||||
the production movement/jump input boundary. No retry masking, teleport, direct
|
||||
physics mutation, auto-complete, or restart shortcut is used.
|
||||
|
||||
## Automated checks
|
||||
|
||||
- Clean Gradle build: `clean testDebugUnitTest lintDebug assembleDebug` — passed.
|
||||
- Unit tests: 8 passed, 0 failed, 0 skipped.
|
||||
- Device acceptance tests: 1 passed, 0 failed, 0 skipped.
|
||||
- Level 5 bot: completed the production level in about 8.2 seconds with exactly one death, one retained corpse, pressure-plate activation, door passage, two jumps, and goal contact.
|
||||
- Android Lint: no issues found.
|
||||
- APK archive integrity: all entries passed `unzip -t`.
|
||||
- Package metadata: `com.projectsacrifice.game.debug`, version code 3, min SDK 23, target SDK 34.
|
||||
- APK signature: v1 and v2 schemes verified; one standard Android Debug signer.
|
||||
- Clean Gradle gate: `clean testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest` — passed, 66 tasks.
|
||||
- JVM tests: 15 passed, 0 failed, 0 skipped.
|
||||
- Integrated device gate: `connectedDebugAndroidTest` — 14 passed, 0 failed, 0 skipped.
|
||||
- Device test time: 349.702 seconds; Gradle wall time: 5 minutes 59 seconds.
|
||||
- Gameplay executions: 30 intended routes (levels 1–10 × 3), three level-8 recovery routes, and two delayed-decision stress routes; lifecycle coverage is a separate method.
|
||||
- Android Lint: passed without errors.
|
||||
|
||||
## Device smoke test
|
||||
The five-minute matrix target is not met: the green run exceeds it by 59 seconds.
|
||||
Correctness coverage remains intact; test-duration optimization stays open.
|
||||
|
||||
Environment: official ARM64 Android Emulator, Android 16 / API 36, 2400×1080 landscape.
|
||||
## Playability evidence
|
||||
|
||||
Verified:
|
||||
- Level 1: movement/jump route, 3/3, zero deaths.
|
||||
- Level 2: intended three-corpse bridge, 3/3, exactly three deaths and bodies.
|
||||
- Level 3: saw corpse holds the pressure door, 3/3, exactly one death/body.
|
||||
- Level 4: toggle, moving lift, and upper gallery, 3/3, zero deaths.
|
||||
- Level 5: saw delivery to remote pressure plate, 3/3, exactly one death/body.
|
||||
- Level 6: frozen corpse stepping stone, 3/3, exactly one death/body.
|
||||
- Level 7: conductive corpse relay, 3/3, exactly one death/body.
|
||||
- Level 8: intended timed-platform route 3/3, expired-timer recovery 3/3, and one 96 ms delayed-decision run; all deathless.
|
||||
- Level 9: weight AND power door, 3/3, exactly two deaths/bodies.
|
||||
- Level 10: saw/freeze/electric/lift procession, 3/3, exactly three deaths/bodies, plus one 96 ms delayed final-traversal run.
|
||||
- Activity background/resume: visible pause, held-input clearing, state preservation, and explicit resume passed.
|
||||
|
||||
1. streamed APK installation returns `Success`;
|
||||
2. cold launch returns `Status: ok`;
|
||||
3. `MainActivity` remains visible and `RESUMED`;
|
||||
4. immersive landscape mode is applied;
|
||||
5. level-selection screen renders correctly;
|
||||
6. entering level 1 renders the HUD, character, goal, and mobile controls;
|
||||
7. holding the right control completes level 1;
|
||||
8. completion records best time/deaths and unlocks level 2 (`unlocked=2`);
|
||||
9. reaching spikes in level 2 increments the volunteer counter, keeps the corpse in the level, and respawns a live volunteer without resetting the scene;
|
||||
10. Android crash buffer remains empty after the corrected build and gameplay smoke tests.
|
||||
11. the level 5 acceptance bot reproduces the complete saw/body/plate/door solution on the same Android 16 emulator; its initial red runs exposed an unstable saw impulse, and the final run passed after the body delivery was corrected.
|
||||
## Defects found and fixed by the integrated gate
|
||||
|
||||
## Distribution note
|
||||
1. Level 6 freezer overlapped the player standing on its frozen body by 26 px. The hazard height was narrowed while retaining floor lethality.
|
||||
2. Level 8 trapped the player beyond the pit after the timer expired. A high far-side recovery relay was added; the original timer received a 0.5-second balance margin.
|
||||
3. Level 8 platform boarding failed under delayed decisions. The left launch floor was extended by 70 px while the pit's far edge, platform, timers, and recovery relay stayed unchanged.
|
||||
4. Level 10 relay clearing and lift boarding were phase-sensitive. The bot now proves actual moving-platform support; floor/lift overlap increased from 40 to 100 px while retaining a 200 px pit and mandatory 410 px lift.
|
||||
|
||||
The delivered APK is a debug-signed tester build. It is directly installable, but it is not intended for Play Store publication. A production release should use the publisher's private release keystore and a non-debug application ID.
|
||||
## APK verification
|
||||
|
||||
- File format: valid ZIP/APK; `unzip -t` reports no errors.
|
||||
- Package: `com.projectsacrifice.game.debug`.
|
||||
- Version: code `4`, name `0.3.0-stage2-playability-debug`.
|
||||
- SDK: min 23, target 34, compile 34.
|
||||
- Signature: v1 and v2 verified; one standard Android Debug signer.
|
||||
- SHA-256: `94652cfe7e7eacbb41d7890944f9adf85b989fde89764809e3b8bfab8e64a12d`.
|
||||
- Installation/runtime: the exact APK was installed and exercised by the final Android 16 connected test task.
|
||||
|
||||
## Remaining scope
|
||||
|
||||
- Stage 2 manual comprehension/balance sessions have not yet been completed.
|
||||
- Possible alternative solutions on levels 2 and 10 still need dedicated negative tests.
|
||||
- Screen-lock and process-death/low-memory lifecycle recovery remain open.
|
||||
- The character is still Canvas-drawn prototype art; there is no skeleton/rig or skin pipeline yet.
|
||||
- This is a debug-signed tester APK, not a Play Store release build.
|
||||
|
||||
@@ -7,6 +7,12 @@ Project documents:
|
||||
- [`docs/DEVELOPMENT_PLAN.md`](docs/DEVELOPMENT_PLAN.md) — approved GDD + TDD + production roadmap;
|
||||
- [`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.
|
||||
|
||||
## Playable content
|
||||
@@ -32,6 +38,15 @@ Death does not reset the level. A new volunteer respawns after a short delay whi
|
||||
- Hold a direction and tap jump simultaneously; multitouch is supported.
|
||||
- `Restart` restores the current level.
|
||||
- `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, 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,
|
||||
saved best, targets, a next goal, and Level select/Replay/Next level routes. Critical
|
||||
signal, door, and respawn state remains visible when vibration is disabled.
|
||||
|
||||
The movement constants include 130 ms coyote time and 140 ms input buffering.
|
||||
|
||||
@@ -43,16 +58,53 @@ Requirements: JDK 17 and Android SDK Platform/Build Tools 34.
|
||||
./gradlew testDebugUnitTest assembleDebug
|
||||
```
|
||||
|
||||
Level 5 has a device-level acceptance bot. It drives the production `GameView`
|
||||
through the same movement and jump inputs as the touch controls, sacrifices one
|
||||
volunteer to the saw, verifies the retained body and pressure-plate door, then
|
||||
reaches the goal. With an emulator or phone connected, run it with:
|
||||
Level content is stored in `level-format/src/main/resources/levels`. Android and
|
||||
the desktop tools use the same dependency-free parser and validator. Android
|
||||
packaging automatically depends on validation. To validate explicitly or export
|
||||
deterministic SVG/TSV previews:
|
||||
|
||||
```sh
|
||||
./gradlew connectedDebugAndroidTest \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class=com.projectsacrifice.game.LevelFivePlaythroughTest
|
||||
./gradlew :level-format:validateLevels
|
||||
./gradlew :level-format:previewLevels
|
||||
```
|
||||
|
||||
Previews are written to `level-format/build/level-previews`. Invalid geometry,
|
||||
versions, moving-platform destinations, switch parameters, or signal references
|
||||
fail the build with a JSON path diagnostic.
|
||||
|
||||
All ten levels have device-level acceptance bots. They drive the production
|
||||
`GameView` through the same movement and jump inputs as the touch controls and
|
||||
run each intended solution three times. Level 8 also has a dedicated recovery
|
||||
scenario for an expired timer on the far side of the pit. Stage 2 additionally
|
||||
checks clean restart and long-term stability of the saw-delivered body on the
|
||||
level 10 lift plate. The final Stage 2 build passed all 16 instrumentation
|
||||
methods (36 gameplay executions plus lifecycle and restart) on Android 16 in a
|
||||
single unfiltered run. With an emulator or phone connected, run the complete
|
||||
matrix with:
|
||||
|
||||
```sh
|
||||
./gradlew connectedDebugAndroidTest
|
||||
```
|
||||
|
||||
The Stage 3 device suite additionally checks persisted controls, RU/EN copy,
|
||||
accessibility presets, tutorial/result navigation, Back behavior, and safe viewports
|
||||
on 20:9, 16:9, and 16:10 tablet-sized displays.
|
||||
|
||||
Stage 4 keeps the same gameplay and UI matrix after migrating all ten levels to
|
||||
JSON. Its final unfiltered Android 16 run passed all 30 instrumentation methods
|
||||
in one process (`OK (30 tests)`, 1,096.448 seconds). Acceptance bots now attach a
|
||||
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. The product owner confirmed completion of
|
||||
external testing on 19 August 2026; the evidence boundary is recorded without
|
||||
invented session data in `docs/STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 5 is accepted.
|
||||
|
||||
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
|
||||
|
||||
+7
-2
@@ -10,8 +10,8 @@ android {
|
||||
applicationId 'com.projectsacrifice.game'
|
||||
minSdk 23
|
||||
targetSdk 34
|
||||
versionCode 3
|
||||
versionName '0.2.1-level5-proof'
|
||||
versionCode 9
|
||||
versionName '0.8.0-stage5-vslice'
|
||||
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
@@ -38,8 +38,13 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':level-format')
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
}
|
||||
|
||||
tasks.named('preBuild').configure {
|
||||
dependsOn ':level-format:validateLevels'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
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 AccessibilityPersistenceTest {
|
||||
@Test
|
||||
public void accessibilityAndControlChoicesSurviveActivityRecreationTogether() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
view.selectNextControlSizeForAutomation();
|
||||
view.selectNextTextSizeForAutomation();
|
||||
view.toggleHighContrastForAutomation();
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
assertEquals("Large", view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertEquals("Large", view.accessibilitySettingsForAutomation().textSizeLabel());
|
||||
assertTrue(view.accessibilitySettingsForAutomation().highContrastEnabled());
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void descriptionsFollowScreensWhileBackAlwaysHasOneDeterministicResult() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> assertContains(
|
||||
current.gameViewForAutomation(), "Level select"));
|
||||
|
||||
activity.onActivity(current -> current.gameViewForAutomation()
|
||||
.openSettingsForAutomation());
|
||||
activity.onActivity(current -> {
|
||||
assertContains(current.gameViewForAutomation(), "Settings");
|
||||
assertContains(current.gameViewForAutomation(), "Text size");
|
||||
current.onBackPressed();
|
||||
assertContains(current.gameViewForAutomation(), "Level select");
|
||||
});
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
assertContains(view, "Playing level 1");
|
||||
view.pauseForAutomation();
|
||||
assertContains(view, "Paused on level 1");
|
||||
current.onBackPressed();
|
||||
assertContains(view, "Playing level 1");
|
||||
assertFalse(view.automationSnapshot().visiblyPaused());
|
||||
view.showCompleteForAutomation();
|
||||
assertContains(view, "Level complete");
|
||||
current.onBackPressed();
|
||||
assertContains(view, "Level select");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void textAndContrastPresetsDoNotMoveContentOutsideTheSafeViewport() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
float[] before = view.safeViewportForAutomation();
|
||||
view.selectNextTextSizeForAutomation();
|
||||
view.toggleHighContrastForAutomation();
|
||||
float[] after = view.safeViewportForAutomation();
|
||||
|
||||
assertTrue(before[0] >= 0f);
|
||||
assertTrue(before[1] >= 0f);
|
||||
assertTrue(before[2] <= view.getWidth());
|
||||
assertTrue(before[3] <= view.getHeight());
|
||||
assertEquals(before[0], after[0], 0f);
|
||||
assertEquals(before[1], after[1], 0f);
|
||||
assertEquals(before[2], after[2], 0f);
|
||||
assertEquals(before[3], after[3], 0f);
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void accessibilityRowsChangeThePersistedSettingsThroughRealTouchInput() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.toggleHapticsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
view.openSettingsForAutomation();
|
||||
});
|
||||
|
||||
tap(activity, 220f, 345f);
|
||||
tap(activity, 220f, 515f);
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
assertEquals(1.25f, view.textScaleForAutomation(), 0f);
|
||||
assertTrue(view.highContrastForAutomation());
|
||||
assertTrue(view.screenDescriptionForAutomation().contains("Text size Large"));
|
||||
assertTrue(view.screenDescriptionForAutomation().contains("High contrast On"));
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void criticalSignalAndDoorStateRemainsTextualWhenVibrationIsOff() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.toggleHapticsForAutomation();
|
||||
view.startLevelForAutomation(3);
|
||||
|
||||
assertEquals("Signals 0/1 · Doors 0/1 · Volunteer active",
|
||||
view.criticalVisibleStatusForAutomation());
|
||||
assertTrue(view.screenDescriptionForAutomation().contains(
|
||||
view.criticalVisibleStatusForAutomation()));
|
||||
view.resetControlSettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
|
||||
touch(activity, MotionEvent.ACTION_DOWN, canvasX, canvasY);
|
||||
touch(activity, MotionEvent.ACTION_UP, canvasX, canvasY);
|
||||
}
|
||||
|
||||
private static void touch(ActivityScenario<MainActivity> activity, int action,
|
||||
float canvasX, float canvasY) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
|
||||
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
|
||||
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
|
||||
long now = SystemClock.uptimeMillis();
|
||||
MotionEvent event = MotionEvent.obtain(now, now, action, x, y, 0);
|
||||
view.dispatchTouchEvent(event);
|
||||
event.recycle();
|
||||
});
|
||||
}
|
||||
|
||||
private static void assertContains(GameView view, String expected) {
|
||||
String description = String.valueOf(view.getContentDescription());
|
||||
assertTrue("Expected '" + expected + "' in '" + description + "'",
|
||||
description.contains(expected));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
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 ControlSettingsPersistenceTest {
|
||||
@Test
|
||||
public void changedControlsSurviveActivityRecreation() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> current.gameViewForAutomation()
|
||||
.resetControlSettingsForAutomation());
|
||||
activity.onActivity(current -> current.gameViewForAutomation()
|
||||
.openSettingsForAutomation());
|
||||
activity.onActivity(current -> assertTrue(current.gameViewForAutomation()
|
||||
.settingsVisibleForAutomation()));
|
||||
tap(activity, 1_250f, 685f);
|
||||
tap(activity, 1_250f, 345f);
|
||||
tap(activity, 1_250f, 515f);
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(current -> {
|
||||
ControlSettings settings = current.gameViewForAutomation()
|
||||
.controlSettingsForAutomation();
|
||||
assertEquals("Large", settings.controlSizeLabel());
|
||||
assertEquals(1.2f, settings.controlScale(), 0f);
|
||||
assertEquals("High", settings.controlOpacityLabel());
|
||||
assertEquals(0.9f, settings.controlOpacity(), 0f);
|
||||
assertFalse(settings.hapticsEnabled());
|
||||
assertFalse(current.gameViewForAutomation().settingsVisibleForAutomation());
|
||||
current.gameViewForAutomation().resetControlSettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void largePresetExpandsTheRealTouchTarget() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
});
|
||||
|
||||
touch(activity, MotionEvent.ACTION_DOWN, 13f, 925f);
|
||||
activity.onActivity(current -> assertFalse(current.gameViewForAutomation()
|
||||
.automationSnapshot().inputActive()));
|
||||
touch(activity, MotionEvent.ACTION_UP, 13f, 925f);
|
||||
|
||||
activity.onActivity(current -> current.gameViewForAutomation()
|
||||
.selectNextControlSizeForAutomation());
|
||||
touch(activity, MotionEvent.ACTION_DOWN, 13f, 925f);
|
||||
activity.onActivity(current -> {
|
||||
assertTrue(current.gameViewForAutomation().automationSnapshot().inputActive());
|
||||
current.gameViewForAutomation().resetControlSettingsForAutomation();
|
||||
});
|
||||
touch(activity, MotionEvent.ACTION_UP, 13f, 925f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void heldJumpDoesNotRepeatAfterLanding() throws Exception {
|
||||
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> {
|
||||
view[0] = current.gameViewForAutomation();
|
||||
view[0].startLevelForAutomation(1);
|
||||
});
|
||||
|
||||
await(instrumentation, view[0], 1_000L,
|
||||
GameView.AutomationSnapshot::playerOnGround);
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view[0].setAutomationInput(false, false, true));
|
||||
await(instrumentation, view[0], 2_000L,
|
||||
snapshot -> snapshot.playerY() <= 700f);
|
||||
await(instrumentation, view[0], 2_000L,
|
||||
snapshot -> snapshot.playerOnGround() && snapshot.playerY() >= 779f);
|
||||
Thread.sleep(350L);
|
||||
|
||||
GameView.AutomationSnapshot settled = snapshot(instrumentation, view[0]);
|
||||
assertTrue("A held jump must not fire again after landing", settled.playerOnGround());
|
||||
assertEquals(780f, settled.playerY(), 1f);
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view[0].setAutomationInput(false, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
|
||||
touch(activity, MotionEvent.ACTION_DOWN, canvasX, canvasY);
|
||||
touch(activity, MotionEvent.ACTION_UP, canvasX, canvasY);
|
||||
}
|
||||
|
||||
private static void touch(ActivityScenario<MainActivity> activity, int action,
|
||||
float canvasX, float canvasY) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
|
||||
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
|
||||
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
|
||||
long now = SystemClock.uptimeMillis();
|
||||
MotionEvent event = MotionEvent.obtain(now, now, action, x, y, 0);
|
||||
view.dispatchTouchEvent(event);
|
||||
event.recycle();
|
||||
});
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot await(
|
||||
Instrumentation instrumentation, GameView view, long timeoutMillis,
|
||||
LevelPlaytestBot.SnapshotCondition condition
|
||||
) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
GameView.AutomationSnapshot current = snapshot(instrumentation, view);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
current = snapshot(instrumentation, view);
|
||||
if (condition.reached(current)) return current;
|
||||
Thread.sleep(32L);
|
||||
}
|
||||
throw new AssertionError("Control input timed out; x=" + current.playerX()
|
||||
+ ", y=" + current.playerY() + ", input=" + current.inputActive());
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot snapshot(
|
||||
Instrumentation instrumentation, GameView view
|
||||
) {
|
||||
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
|
||||
return value[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
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;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/** Production-input acceptance coverage for the intended routes through levels 1-4. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class EarlyLevelPlaythroughTest {
|
||||
@Test
|
||||
public void levelOneProvesMovementAndJumpWithoutDeaths() {
|
||||
runThreeConsecutive(EarlyLevelScenarios.levelOne(), 20_000L, (pass, result) -> {
|
||||
assertTrue(passDiagnostic(pass, result), result.completed());
|
||||
assertEquals("Pass " + pass + ": level 1 must not require a volunteer",
|
||||
0, result.deaths());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void levelTwoBuildsTheIntendedThreeCorpseBridge() {
|
||||
runThreeConsecutive(EarlyLevelScenarios.levelTwo(), 30_000L, (pass, result) -> {
|
||||
assertTrue(passDiagnostic(pass, result), result.completed());
|
||||
assertEquals("Pass " + pass + ": the intended bridge uses three volunteers",
|
||||
3, result.deaths());
|
||||
assertEquals("Pass " + pass + ": all three bridge bodies must remain",
|
||||
3, result.corpseCount());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void levelThreeLeavesASawCorpseOnThePlateAndOpensTheDoor() {
|
||||
runThreeConsecutive(EarlyLevelScenarios.levelThree(), 25_000L, (pass, result) -> {
|
||||
assertTrue(passDiagnostic(pass, result), result.completed());
|
||||
assertEquals("Pass " + pass + ": the saw solution requires one volunteer",
|
||||
1, result.deaths());
|
||||
assertEquals("Pass " + pass + ": the plate body must remain",
|
||||
1, result.corpseCount());
|
||||
assertTrue("Pass " + pass + ": the corpse must keep the pressure door open",
|
||||
result.doorOpened());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void levelFourTogglesAndRidesTheLiftToTheUpperGallery() {
|
||||
runThreeConsecutive(EarlyLevelScenarios.levelFour(), 30_000L, (pass, result) -> {
|
||||
assertTrue(passDiagnostic(pass, result), result.completed());
|
||||
assertEquals("Pass " + pass + ": the lift route must not require a volunteer",
|
||||
0, result.deaths());
|
||||
});
|
||||
}
|
||||
|
||||
private static String passDiagnostic(int pass, LevelPlaytestBot.Result result) {
|
||||
return "Consecutive pass " + pass + ": " + result.diagnostic();
|
||||
}
|
||||
|
||||
private static void runThreeConsecutive(LevelPlaytestBot.Scenario scenario, long timeoutMillis,
|
||||
ResultAssertion assertion) {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
final GameView[] view = new GameView[1];
|
||||
activity.onActivity(mainActivity -> view[0] = mainActivity.gameViewForAutomation());
|
||||
AndroidDriver driver = new AndroidDriver(
|
||||
InstrumentationRegistry.getInstrumentation(), view[0]);
|
||||
LevelPlaytestBot bot = new LevelPlaytestBot();
|
||||
for (int pass = 1; pass <= 3; pass++) {
|
||||
assertion.verify(pass, bot.run(driver, scenario, timeoutMillis));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ResultAssertion {
|
||||
void verify(int pass, LevelPlaytestBot.Result result);
|
||||
}
|
||||
|
||||
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView gameView;
|
||||
|
||||
AndroidDriver(Instrumentation instrumentation, GameView gameView) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.gameView = gameView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
instrumentation.runOnMainSync(() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
final GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = gameView.automationSnapshot());
|
||||
return value[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Early-level bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
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 GameLanguagePersistenceTest {
|
||||
@Test
|
||||
public void settingsLanguageSwitchSurvivesRecreationWithoutChangingProgressOrControls() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
int[] expectedUnlocked = new int[1];
|
||||
int[] expectedBestDeaths = new int[1];
|
||||
long[] expectedBestTime = new long[1];
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetLanguageForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.selectNextControlSizeForAutomation();
|
||||
view.selectNextControlOpacityForAutomation();
|
||||
view.toggleHapticsForAutomation();
|
||||
|
||||
ProgressStore progress = new ProgressStore(current);
|
||||
progress.recordCompletion(2, 4, 22_222L);
|
||||
expectedUnlocked[0] = progress.unlockedCount();
|
||||
expectedBestDeaths[0] = progress.bestDeaths(2);
|
||||
expectedBestTime[0] = progress.bestTimeMs(2);
|
||||
});
|
||||
|
||||
tap(activity, 1_700f, 115f); // Settings from level select.
|
||||
activity.onActivity(current -> assertTrue(current.gameViewForAutomation()
|
||||
.settingsVisibleForAutomation()));
|
||||
tap(activity, 1_700f, 115f); // Language switch on Settings.
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
assertEquals(GameLanguage.RU, view.languageForAutomation());
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("НАСТРОЙКИ"));
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("Русский"));
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
assertEquals(GameLanguage.RU, view.languageForAutomation());
|
||||
assertFalse(view.settingsVisibleForAutomation());
|
||||
|
||||
ControlSettings settings = view.controlSettingsForAutomation();
|
||||
assertEquals("Large", settings.controlSizeLabel());
|
||||
assertEquals("High", settings.controlOpacityLabel());
|
||||
assertFalse(settings.hapticsEnabled());
|
||||
|
||||
ProgressStore progress = new ProgressStore(current);
|
||||
assertEquals(expectedUnlocked[0], progress.unlockedCount());
|
||||
assertEquals(expectedBestDeaths[0], progress.bestDeaths(2));
|
||||
assertEquals(expectedBestTime[0], progress.bestTimeMs(2));
|
||||
|
||||
view.setLanguageForAutomation(GameLanguage.EN);
|
||||
view.resetControlSettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
|
||||
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
|
||||
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
|
||||
long now = SystemClock.uptimeMillis();
|
||||
MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0);
|
||||
view.dispatchTouchEvent(down);
|
||||
down.recycle();
|
||||
MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0);
|
||||
view.dispatchTouchEvent(up);
|
||||
up.recycle();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.projectsacrifice.game;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
@@ -21,12 +20,16 @@ public final class GameLifecycleTest {
|
||||
public void returningFromBackgroundKeepsPuzzleVisiblePausedAndClearsHeldInput() {
|
||||
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
|
||||
final MainActivity[] activity = new MainActivity[1];
|
||||
final GameView[] view = new GameView[1];
|
||||
scenario.onActivity(activity -> view[0] = activity.gameViewForAutomation());
|
||||
scenario.onActivity(current -> {
|
||||
activity[0] = current;
|
||||
view[0] = current.gameViewForAutomation();
|
||||
});
|
||||
createRetainedCorpse(instrumentation, view[0]);
|
||||
|
||||
runOnGame(instrumentation, view[0], false, false, true, false);
|
||||
scenario.moveToState(Lifecycle.State.STARTED);
|
||||
instrumentation.runOnMainSync(activity[0]::onPause);
|
||||
GameView.AutomationSnapshot backgroundStart = snapshot(instrumentation, view[0]);
|
||||
sleep(700L);
|
||||
GameView.AutomationSnapshot backgroundEnd = snapshot(instrumentation, view[0]);
|
||||
@@ -37,7 +40,7 @@ public final class GameLifecycleTest {
|
||||
assertEquals(backgroundStart.playerX(), backgroundEnd.playerX(), 0.01f);
|
||||
assertEquals(backgroundStart.elapsedMilliseconds(), backgroundEnd.elapsedMilliseconds());
|
||||
|
||||
scenario.moveToState(Lifecycle.State.RESUMED);
|
||||
instrumentation.runOnMainSync(activity[0]::onResume);
|
||||
GameView.AutomationSnapshot returned = snapshot(instrumentation, view[0]);
|
||||
assertTrue("Returning to an active puzzle must show the pause screen", returned.visiblyPaused());
|
||||
assertFalse("Held touch input must not survive backgrounding", returned.inputActive());
|
||||
@@ -49,13 +52,31 @@ public final class GameLifecycleTest {
|
||||
GameView.AutomationSnapshot resumed = snapshot(instrumentation, view[0]);
|
||||
assertFalse("Back from the visible pause screen must resume the puzzle", resumed.visiblyPaused());
|
||||
runOnGame(instrumentation, view[0], false, false, true, false);
|
||||
sleep(250L);
|
||||
GameView.AutomationSnapshot moving = waitForMovement(
|
||||
instrumentation, view[0], resumed.playerX(), 10_000L);
|
||||
runOnGame(instrumentation, view[0], false, false, false, false);
|
||||
GameView.AutomationSnapshot moving = snapshot(instrumentation, view[0]);
|
||||
assertTrue("The resumed simulation must accept movement", moving.playerX() > resumed.playerX() + 20f);
|
||||
}
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot waitForMovement(
|
||||
Instrumentation instrumentation, GameView gameView, float startX, long timeoutMillis) {
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
GameView.AutomationSnapshot current = snapshot(instrumentation, gameView);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (current.playerX() > startX + 20f) return current;
|
||||
sleep(24L);
|
||||
current = snapshot(instrumentation, gameView);
|
||||
}
|
||||
throw new AssertionError(
|
||||
"The resumed simulation did not move within " + timeoutMillis
|
||||
+ "ms; startX=" + startX
|
||||
+ ", finalX=" + current.playerX()
|
||||
+ ", alive=" + current.playerAlive()
|
||||
+ ", paused=" + current.visiblyPaused()
|
||||
+ ", inputActive=" + current.inputActive());
|
||||
}
|
||||
|
||||
private static void createRetainedCorpse(Instrumentation instrumentation, GameView gameView) {
|
||||
runOnGame(instrumentation, gameView, true, false, true, false);
|
||||
long deadline = System.currentTimeMillis() + 8_000L;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class GameRestartTest {
|
||||
@Test
|
||||
public void restartClearsBodiesSignalsTimerAndHeldInput() throws Exception {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view[0].startLevelForAutomation(10);
|
||||
view[0].setAutomationInput(false, true, false);
|
||||
});
|
||||
await(instrumentation, view[0], 4_000L,
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1);
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view[0].setAutomationInput(false, false, false));
|
||||
await(instrumentation, view[0], 5_000L,
|
||||
GameView.AutomationSnapshot::firstMovingPlatformPowered);
|
||||
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view[0].setAutomationInput(false, true, true);
|
||||
view[0].restartLevelForAutomation();
|
||||
});
|
||||
GameView.AutomationSnapshot restarted = snapshot(instrumentation, view[0]);
|
||||
|
||||
assertEquals(10, restarted.levelNumber());
|
||||
assertTrue(restarted.playerAlive());
|
||||
assertEquals(0, restarted.deaths());
|
||||
assertEquals(0, restarted.corpseCount());
|
||||
assertFalse(restarted.firstMovingPlatformPowered());
|
||||
assertFalse(restarted.inputActive());
|
||||
assertTrue("Restarted timer must begin near zero: "
|
||||
+ restarted.elapsedMilliseconds(), restarted.elapsedMilliseconds() < 200L);
|
||||
assertTrue("Restarted player must be at the level spawn: x=" + restarted.playerX(),
|
||||
restarted.playerX() >= 125f && restarted.playerX() <= 135f);
|
||||
assertEquals(790f, restarted.firstMovingPlatformY(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot await(
|
||||
Instrumentation instrumentation,
|
||||
GameView view,
|
||||
long timeoutMillis,
|
||||
LevelPlaytestBot.SnapshotCondition condition
|
||||
) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
GameView.AutomationSnapshot current = snapshot(instrumentation, view);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
current = snapshot(instrumentation, view);
|
||||
if (condition.reached(current)) return current;
|
||||
Thread.sleep(32L);
|
||||
}
|
||||
throw new AssertionError("Restart setup timed out; level=" + current.levelNumber()
|
||||
+ ", x=" + current.playerX() + ", deaths=" + current.deaths()
|
||||
+ ", corpses=" + current.corpseCount());
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot snapshot(
|
||||
Instrumentation instrumentation,
|
||||
GameView view
|
||||
) {
|
||||
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
|
||||
return value[0];
|
||||
}
|
||||
}
|
||||
@@ -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,256 @@
|
||||
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;
|
||||
|
||||
/** Production-input acceptance coverage for levels 6, 7, 9 and 10. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class LateLevelPlaythroughTest {
|
||||
private static final int CONSECUTIVE_RUNS = 3;
|
||||
|
||||
@Test
|
||||
public void frozenVolunteerIsUsedToCompleteLevelSixThreeTimes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameDriver driver = GameDriver.from(activity);
|
||||
|
||||
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
driver,
|
||||
LateLevelScenarios.levelSix(),
|
||||
30_000L
|
||||
);
|
||||
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run + "; intended route uses one frozen volunteer",
|
||||
1, result.deaths());
|
||||
assertEquals("run=" + run + "; frozen block must remain in the puzzle",
|
||||
1, result.corpseCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conductiveVolunteerKeepsLevelSevenDoorOpenThreeTimes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameDriver driver = GameDriver.from(activity);
|
||||
|
||||
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
driver,
|
||||
LateLevelScenarios.levelSeven(),
|
||||
25_000L
|
||||
);
|
||||
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run + "; intended route uses one conductive volunteer",
|
||||
1, result.deaths());
|
||||
assertEquals("run=" + run + "; conductive body must remain in the relay",
|
||||
1, result.corpseCount());
|
||||
assertTrue("run=" + run + "; conductive relay must open the door",
|
||||
result.doorOpened());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void weightAndPowerOpenLevelNineDoorThreeTimes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameDriver driver = GameDriver.from(activity);
|
||||
|
||||
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
driver,
|
||||
LateLevelScenarios.levelNine(),
|
||||
35_000L
|
||||
);
|
||||
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run + "; weight and power require two volunteers",
|
||||
2, result.deaths());
|
||||
assertEquals("run=" + run + "; both signal bodies must remain",
|
||||
2, result.corpseCount());
|
||||
assertTrue("run=" + run + "; both signals must open the AND door",
|
||||
result.doorOpened());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masteryProcessionCompletesLevelTenThreeTimes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameDriver driver = GameDriver.from(activity);
|
||||
|
||||
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
driver,
|
||||
LateLevelScenarios.levelTen(),
|
||||
55_000L
|
||||
);
|
||||
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run + "; mastery route uses three volunteers",
|
||||
3, result.deaths());
|
||||
assertEquals("run=" + run + "; launch, freeze and relay bodies must remain",
|
||||
3, result.corpseCount());
|
||||
assertTrue("run=" + run + "; conductive relay must open the final door",
|
||||
result.doorOpened());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masteryProcessionSurvivesThrottledFinalTraversalDecisions() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameDriver driver = GameDriver.withFinalTraversalDelay(activity, 96L);
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
driver,
|
||||
LateLevelScenarios.levelTen(),
|
||||
65_000L
|
||||
);
|
||||
|
||||
assertTrue(result.diagnostic(), result.completed());
|
||||
assertEquals("throttling must not add accidental volunteers", 3, result.deaths());
|
||||
assertEquals("all three intended bodies must remain", 3, result.corpseCount());
|
||||
assertTrue("the conductive relay must still open the final door",
|
||||
result.doorOpened());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sawDeliveredBodyKeepsLevelTenLiftPoweredAfterSettling() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameDriver driver = GameDriver.from(activity);
|
||||
driver.startLevel(10);
|
||||
await(driver, 4_000L, false, true, false,
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1);
|
||||
GameView.AutomationSnapshot engaged = await(driver, 5_000L, false, false, false,
|
||||
GameView.AutomationSnapshot::firstMovingPlatformPowered);
|
||||
driver.setInput(false, false, false);
|
||||
driver.waitForNextDecision(750L);
|
||||
|
||||
GameView.AutomationSnapshot settled = driver.snapshot();
|
||||
assertEquals("The body must settle when it engages the lift plate instead of sliding",
|
||||
engaged.firstCorpseX(), settled.firstCorpseX(), 2f);
|
||||
|
||||
driver.waitForNextDecision(6_000L);
|
||||
|
||||
settled = driver.snapshot();
|
||||
assertTrue("The saw-delivered body slid off the lift plate after settling; "
|
||||
+ "platformY=" + settled.firstMovingPlatformY()
|
||||
+ ", deaths=" + settled.deaths()
|
||||
+ ", corpses=" + settled.corpseCount(),
|
||||
settled.firstMovingPlatformPowered());
|
||||
assertEquals("The stability wait must not consume another volunteer",
|
||||
1, settled.deaths());
|
||||
assertEquals("The delivered body must remain in the level",
|
||||
1, settled.corpseCount());
|
||||
}
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot await(
|
||||
GameDriver driver,
|
||||
long timeoutMillis,
|
||||
boolean moveLeft,
|
||||
boolean moveRight,
|
||||
boolean jump,
|
||||
LevelPlaytestBot.SnapshotCondition condition
|
||||
) {
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
snapshot = driver.snapshot();
|
||||
if (condition.reached(snapshot)) {
|
||||
driver.setInput(false, false, false);
|
||||
return snapshot;
|
||||
}
|
||||
driver.setInput(moveLeft, moveRight, jump);
|
||||
driver.waitForNextDecision(32L);
|
||||
}
|
||||
throw new AssertionError("Timed out waiting for level 10 lift body; x="
|
||||
+ snapshot.playerX() + ", y=" + snapshot.playerY()
|
||||
+ ", deaths=" + snapshot.deaths()
|
||||
+ ", corpses=" + snapshot.corpseCount()
|
||||
+ ", platformY=" + snapshot.firstMovingPlatformY());
|
||||
}
|
||||
|
||||
private static final class GameDriver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView gameView;
|
||||
private final long minimumDecisionMillis;
|
||||
private final boolean throttleFinalTraversalOnly;
|
||||
private GameView.AutomationSnapshot lastSnapshot;
|
||||
|
||||
private GameDriver(Instrumentation instrumentation, GameView gameView,
|
||||
long minimumDecisionMillis, boolean throttleFinalTraversalOnly) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.gameView = gameView;
|
||||
this.minimumDecisionMillis = minimumDecisionMillis;
|
||||
this.throttleFinalTraversalOnly = throttleFinalTraversalOnly;
|
||||
}
|
||||
|
||||
static GameDriver from(ActivityScenario<MainActivity> activity) {
|
||||
return create(activity, 32L, false);
|
||||
}
|
||||
|
||||
static GameDriver withFinalTraversalDelay(ActivityScenario<MainActivity> activity,
|
||||
long minimumDecisionMillis) {
|
||||
return create(activity, minimumDecisionMillis, true);
|
||||
}
|
||||
|
||||
private static GameDriver create(ActivityScenario<MainActivity> activity,
|
||||
long minimumDecisionMillis,
|
||||
boolean throttleFinalTraversalOnly) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
return new GameDriver(InstrumentationRegistry.getInstrumentation(), view[0],
|
||||
minimumDecisionMillis, throttleFinalTraversalOnly);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = gameView.automationSnapshot());
|
||||
lastSnapshot = value[0];
|
||||
return lastSnapshot;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
boolean inFinalTraversalWindow = throttleFinalTraversalOnly
|
||||
&& lastSnapshot != null
|
||||
&& lastSnapshot.levelNumber() == 10
|
||||
&& lastSnapshot.deaths() == 3
|
||||
&& lastSnapshot.playerX() >= 1_200f
|
||||
&& lastSnapshot.playerX() <= 2_200f;
|
||||
Thread.sleep(inFinalTraversalWindow
|
||||
? Math.max(millis, minimumDecisionMillis) : millis);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Late-level bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Declarative intended-route scenarios for the late vertical-slice levels. */
|
||||
final class LateLevelScenarios {
|
||||
private LateLevelScenarios() {}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelSix() {
|
||||
return LevelPlaytestBot.Scenario.builder(6, "Level 6 - frozen corpse step")
|
||||
.step("Freeze one volunteer", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the frozen block", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 390f)
|
||||
.step("Jump toward the frozen block", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 640f)
|
||||
.step("Stand on the frozen block", 2_500L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 626f
|
||||
&& snapshot.playerY() >= 668f
|
||||
&& snapshot.playerY() <= 690f)
|
||||
.step("Land on the first ledge from the frozen block", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 810f
|
||||
&& snapshot.playerY() <= 500f))
|
||||
.step("Cross the first ledge", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 1_230f)
|
||||
.step("Jump the gap to the second ledge", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 1_400f
|
||||
&& snapshot.playerY() <= 520f)
|
||||
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelSeven() {
|
||||
return LevelPlaytestBot.Scenario.builder(7, "Level 7 - conductive relay")
|
||||
.step("Power the relay with one volunteer", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1)
|
||||
.step("Observe the powered door", 1_500L, LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::allDoorsOpen)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the live relay", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 460f)
|
||||
.step("Take off before the conductive relay", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f)
|
||||
.step("Clear the conductive relay", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 780f)
|
||||
.step("Pass the powered door", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 1_230f)
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelNine() {
|
||||
return LevelPlaytestBot.Scenario.builder(9, "Level 9 - weight AND power")
|
||||
.step("Leave one volunteer on the weight plate", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1
|
||||
&& !snapshot.allDoorsOpen())
|
||||
.step("Wait for the second volunteer", 3_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the weighted spikes", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 390f)
|
||||
.step("Jump over the weighted spikes", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
.step("Approach the unpowered relay", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 820f)
|
||||
.step("Power the relay with the second volunteer", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 2
|
||||
&& snapshot.corpseCount() == 2)
|
||||
.step("Observe weight AND power opening the door", 1_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::allDoorsOpen)
|
||||
.step("Wait for the final volunteer", 3_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Return to the weighted spikes", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 390f)
|
||||
.step("Clear the weighted spikes", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
.step("Approach the powered relay", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 720f)
|
||||
.step("Take off before the powered relay", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f)
|
||||
.step("Clear the powered relay", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_040f)
|
||||
.step("Pass the AND door", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 1_610f)
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelTen() {
|
||||
return LevelPlaytestBot.Scenario.builder(10, "Level 10 - mastery procession")
|
||||
.step("Launch one volunteer toward the lift plate", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1)
|
||||
.step("Observe the delivered body power the lift", 3_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1
|
||||
&& snapshot.firstMovingPlatformPowered())
|
||||
.step("Wait for the freezer volunteer", 3_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the launch saw", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
|
||||
.step("Take off before the launch saw", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 580f))
|
||||
.step("Clear the launch saw", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 680f))
|
||||
.step("Approach the delivered lift body", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
.step("Jump over the delivered lift body", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 950f)
|
||||
.step("Enter the freezer", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 2
|
||||
&& snapshot.corpseCount() == 2)
|
||||
.step("Wait for the relay volunteer", 3_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Return to the launch saw", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
|
||||
.step("Take off before the saw again", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 580f))
|
||||
.step("Clear the saw again", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 680f))
|
||||
.step("Approach the lift body again", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
.step("Clear the lift body again", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 860f)
|
||||
.step("Release after the lift-body jump", 500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.inputActive())
|
||||
.step("Land after the lift-body jump", 1_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerOnGround())
|
||||
.step("Back away from the freezer", 1_000L,
|
||||
LevelPlaytestBot.Input.left(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() <= 960f)
|
||||
.step("Jump onto the frozen block", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_040f
|
||||
&& snapshot.playerY() <= 700f)
|
||||
.step("Settle on the frozen block", 1_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 690f)
|
||||
.step("Jump from the frozen block", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_240f)
|
||||
.step("Power the final relay", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 3
|
||||
&& snapshot.corpseCount() == 3)
|
||||
.step("Observe the final door open", 1_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::allDoorsOpen)
|
||||
.step("Wait for the final volunteer", 3_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the saw for the final crossing", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
|
||||
.step("Take off for the final saw crossing", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 580f))
|
||||
.step("Clear the saw for the final crossing", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 680f))
|
||||
.step("Approach the retained lift body", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
.step("Clear the retained lift body", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 860f)
|
||||
.step("Release after the retained-body jump", 500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.inputActive())
|
||||
.step("Land after the retained-body jump", 1_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerOnGround())
|
||||
.step("Back away from the retained freezer", 1_000L,
|
||||
LevelPlaytestBot.Input.left(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() <= 960f)
|
||||
.step("Jump onto the retained frozen block", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_040f
|
||||
&& snapshot.playerY() <= 700f)
|
||||
.step("Settle on the retained frozen block", 1_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 690f)
|
||||
.step("Jump from the retained frozen block", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_240f)
|
||||
.step("Land before the powered relay", 1_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerY() >= 779f)
|
||||
.step("Approach the powered relay", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 1_320f)
|
||||
.step("Take off before the final relay", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f)
|
||||
.step("Clear the powered relay", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_650f)
|
||||
.step("Observe the lift above the launch floor", 6_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !Float.isNaN(snapshot.firstMovingPlatformY())
|
||||
&& snapshot.firstMovingPlatformY() <= 650f)
|
||||
.step("Wait for the lift at floor height", 9_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& (snapshot.playerOnMovingPlatform()
|
||||
|| (snapshot.playerY() >= 770f
|
||||
&& !Float.isNaN(snapshot.firstMovingPlatformY())
|
||||
&& snapshot.firstMovingPlatformY() >= 750f
|
||||
&& snapshot.firstMovingPlatformMovingDown())))
|
||||
.step("Take off toward the moving lift", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& (snapshot.playerOnMovingPlatform()
|
||||
|| snapshot.playerY() <= 670f))
|
||||
.step("Board the moving lift", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
GameView.AutomationSnapshot::playerOnMovingPlatform))
|
||||
.step("Brake toward the lift center", 1_000L,
|
||||
LevelPlaytestBot.Input.left(),
|
||||
snapshot -> snapshot.playerOnMovingPlatform()
|
||||
&& snapshot.playerX() <= 1_900f)
|
||||
.step("Center on the moving lift", 1_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerOnMovingPlatform()
|
||||
&& snapshot.playerX() >= 1_920f)
|
||||
.step("Ride the lift to the upper door", 7_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerY() <= 310f)
|
||||
.step("Move to the raised lift edge", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 2_000f
|
||||
&& snapshot.playerY() <= 340f)
|
||||
.step("Jump through the door to the upper gallery", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 2_400f
|
||||
&& snapshot.playerY() <= 360f)
|
||||
.step("Reach the final relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
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;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/** Device acceptance tests for the level-8 timed-platform puzzle and recovery route. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class LevelEightPlaythroughTest {
|
||||
private static final int REQUIRED_CONSECUTIVE_RUNS = 3;
|
||||
|
||||
@Test
|
||||
public void expiredClockCanBeRearmedFromTheFarSideWithoutRestart() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
AndroidDriver driver = driver(activity);
|
||||
for (int run = 1; run <= REQUIRED_CONSECUTIVE_RUNS; run++) {
|
||||
LevelEightScenario.Result result = new LevelEightScenario()
|
||||
.recoverAfterClockExpires(driver, 20_000L);
|
||||
assertTrue("Recovery run " + run + ": " + result.diagnostic(), result.completed());
|
||||
assertEquals("Recovery must not hide a reset/death shortcut", 0, result.deaths());
|
||||
assertTrace(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void intendedTimedPlatformRouteCompletesThreeConsecutiveTimes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
AndroidDriver driver = driver(activity);
|
||||
for (int run = 1; run <= REQUIRED_CONSECUTIVE_RUNS; run++) {
|
||||
LevelEightScenario.Result result = new LevelEightScenario()
|
||||
.completeIntendedRoute(driver, 15_000L);
|
||||
assertTrue("Intended run " + run + ": " + result.diagnostic(), result.completed());
|
||||
assertEquals("The intended route is deathless", 0, result.deaths());
|
||||
assertTrace(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void intendedRouteSurvivesStressedDecisionScheduling() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
LevelEightScenario.Result result = new LevelEightScenario()
|
||||
.completeIntendedRoute(driver(activity, 96L), 20_000L);
|
||||
|
||||
assertTrue("Stressed scheduling: " + result.diagnostic(), result.completed());
|
||||
assertEquals("The stressed intended route is deathless", 0, result.deaths());
|
||||
assertTrace(result);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertTrace(LevelEightScenario.Result result) {
|
||||
assertTrue(result.traceJson(), result.traceJson().contains("\"traceVersion\":1"));
|
||||
assertTrue(result.traceJson(), result.traceJson().contains("\"level\":8"));
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("traceId=" + result.traceId()));
|
||||
}
|
||||
|
||||
private static AndroidDriver driver(ActivityScenario<MainActivity> activity) {
|
||||
return driver(activity, 0L);
|
||||
}
|
||||
|
||||
private static AndroidDriver driver(ActivityScenario<MainActivity> activity,
|
||||
long extraDecisionDelayMillis) {
|
||||
final GameView[] view = new GameView[1];
|
||||
activity.onActivity(mainActivity -> view[0] = mainActivity.gameViewForAutomation());
|
||||
return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0],
|
||||
extraDecisionDelayMillis);
|
||||
}
|
||||
|
||||
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView gameView;
|
||||
private final long extraDecisionDelayMillis;
|
||||
|
||||
AndroidDriver(Instrumentation instrumentation, GameView gameView,
|
||||
long extraDecisionDelayMillis) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.gameView = gameView;
|
||||
this.extraDecisionDelayMillis = extraDecisionDelayMillis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
final GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = gameView.automationSnapshot());
|
||||
return value[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis + extraDecisionDelayMillis);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Level-8 bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Black-box level-8 scenarios driven only through the production input boundary. */
|
||||
final class LevelEightScenario {
|
||||
private static final long DECISION_MILLIS = 32L;
|
||||
private static final float LAUNCH_PLATFORM_X = 1_150f;
|
||||
|
||||
private String lastStep = "Start level 8";
|
||||
|
||||
static final class Result {
|
||||
private final boolean completed;
|
||||
private final int deaths;
|
||||
private final String diagnostic;
|
||||
private final String traceJson;
|
||||
private final String traceId;
|
||||
|
||||
Result(boolean completed, int deaths, String diagnostic, BotCommandTrace trace) {
|
||||
this.completed = completed;
|
||||
this.deaths = deaths;
|
||||
this.traceJson = trace.json();
|
||||
this.traceId = trace.id();
|
||||
this.diagnostic = diagnostic + "; traceId=" + traceId;
|
||||
}
|
||||
|
||||
boolean completed() { return completed; }
|
||||
int deaths() { return deaths; }
|
||||
String diagnostic() { return diagnostic; }
|
||||
String traceJson() { return traceJson; }
|
||||
String traceId() { return traceId; }
|
||||
}
|
||||
|
||||
Result recoverAfterClockExpires(LevelPlaytestBot.Driver driver, long timeoutMillis) {
|
||||
driver = new BotTraceDriver(driver, "Level 8 recovery route");
|
||||
driver.startLevel(8);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(
|
||||
snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis);
|
||||
snapshot = crossPit(driver, budget);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Cross the pit before the clock expires", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = await(driver, budget, "Move clear of the platform sweep",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_680f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Move clear of the platform sweep", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = await(driver, budget, "Wait for the far-side trap",
|
||||
LevelPlaytestBot.Input.none(),
|
||||
current -> current.playerX() >= 1_510f && !current.allDoorsOpen());
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Wait for the far-side trap", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = positionBeneathRecoveryRelay(driver, budget);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Return beneath the recovery relay", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = pulseRightJumpThenAwait(driver, budget, "Jump into the recovery relay",
|
||||
current -> current.allDoorsOpen());
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Jump into the recovery relay", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = await(driver, budget, "Land after rearming the clock",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_650f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Land after rearming the clock", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
return finishFromFarSide(driver, budget, snapshot, "Finish after recovery");
|
||||
}
|
||||
|
||||
Result completeIntendedRoute(LevelPlaytestBot.Driver driver, long timeoutMillis) {
|
||||
driver = new BotTraceDriver(driver, "Level 8 intended route");
|
||||
driver.startLevel(8);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(
|
||||
snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis);
|
||||
snapshot = crossPit(driver, budget);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Cross the pit before the clock expires", snapshot, budget, driver);
|
||||
}
|
||||
return finishFromFarSide(driver, budget, snapshot, "Finish on the original timer");
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver,
|
||||
PlaytestSimulationBudget budget) {
|
||||
GameView.AutomationSnapshot snapshot = await(driver, budget, "Trigger the left timer",
|
||||
LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::allDoorsOpen);
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
long timerStartedAt = snapshot.elapsedMilliseconds();
|
||||
|
||||
snapshot = await(driver, budget, "Approach the pit",
|
||||
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f);
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
snapshot = await(driver, budget, "Wait for the outbound moving platform",
|
||||
LevelPlaytestBot.Input.none(),
|
||||
current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L
|
||||
&& MovingPlatformLaunchPolicy.readyToBoard(current, 1_050f));
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
traceStep(driver, "Jump onto the moving platform");
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
snapshot = await(driver, budget, "Land on the moving platform",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::playerOnMovingPlatform);
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
snapshot = await(driver, budget, "Ride the moving platform to the launch zone",
|
||||
LevelPlaytestBot.Input.none(),
|
||||
current -> MovingPlatformLaunchPolicy.ready(current, LAUNCH_PLATFORM_X));
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
traceStep(driver, "Jump from the moving platform");
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
snapshot = await(driver, budget, "Jump from the moving platform to the far floor",
|
||||
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f);
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
snapshot = awaitFarFloorLandingOnOriginalTimer(driver, budget);
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
return await(driver, budget, "Move under the relay after landing",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_600f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot awaitFarFloorLandingOnOriginalTimer(
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
|
||||
) {
|
||||
GameView.AutomationSnapshot snapshot = await(driver, budget,
|
||||
"Land on the far floor", LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_510f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
return await(driver, budget, "Stabilize on the far floor",
|
||||
LevelPlaytestBot.Input.left(),
|
||||
current -> current.playerX() < 1_580f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
}
|
||||
|
||||
private Result finishFromFarSide(LevelPlaytestBot.Driver driver,
|
||||
PlaytestSimulationBudget budget,
|
||||
GameView.AutomationSnapshot snapshot, String step) {
|
||||
snapshot = positionForFinalJump(driver, budget);
|
||||
if (failed(snapshot, budget, driver) || !snapshot.allDoorsOpen()) {
|
||||
return failure("Reach the open-door jump", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
traceStep(driver, "Final ledge jump");
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
snapshot = await(driver, budget, step, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed);
|
||||
driver.setInput(false, false, false);
|
||||
if (!snapshot.completed()) return failure(step, snapshot, budget, driver);
|
||||
BotCommandTrace trace = finishTrace(driver, "COMPLETED", step, snapshot);
|
||||
return new Result(true, snapshot.deaths(), step + " completed; "
|
||||
+ budget.timingState(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
+ "; " + state(snapshot), trace);
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot positionForFinalJump(
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
|
||||
) {
|
||||
lastStep = "Reach the open-door jump";
|
||||
traceStep(driver, lastStep);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
&& snapshot.playerAlive() && !snapshot.completed() && snapshot.allDoorsOpen()) {
|
||||
if (snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 1_690f && snapshot.playerX() <= 1_730f) {
|
||||
driver.setInput(false, false, false);
|
||||
return snapshot;
|
||||
}
|
||||
if (snapshot.playerX() > 1_730f) {
|
||||
driver.setInput(true, false, false);
|
||||
} else {
|
||||
driver.setInput(false, true, false);
|
||||
}
|
||||
driver.waitForNextDecision(DECISION_MILLIS);
|
||||
snapshot = driver.snapshot();
|
||||
}
|
||||
driver.setInput(false, false, false);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot pulseRightJumpThenAwait(
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step,
|
||||
LevelPlaytestBot.SnapshotCondition completion
|
||||
) {
|
||||
traceStep(driver, step);
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
return await(driver, budget, step, LevelPlaytestBot.Input.right(), completion);
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot positionBeneathRecoveryRelay(
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
|
||||
) {
|
||||
lastStep = "Return beneath the recovery relay";
|
||||
traceStep(driver, lastStep);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
&& snapshot.playerAlive() && !snapshot.completed()) {
|
||||
if (snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 1_620f && snapshot.playerX() <= 1_650f) {
|
||||
driver.setInput(false, false, false);
|
||||
return snapshot;
|
||||
}
|
||||
if (snapshot.playerX() > 1_650f) {
|
||||
driver.setInput(true, false, false);
|
||||
} else {
|
||||
driver.setInput(false, true, false);
|
||||
}
|
||||
driver.waitForNextDecision(DECISION_MILLIS);
|
||||
snapshot = driver.snapshot();
|
||||
}
|
||||
driver.setInput(false, false, false);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot await(
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step,
|
||||
LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion
|
||||
) {
|
||||
lastStep = step;
|
||||
traceStep(driver, step);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
&& snapshot.playerAlive()
|
||||
&& !snapshot.completed() && !completion.reached(snapshot)) {
|
||||
setInput(driver, input);
|
||||
driver.waitForNextDecision(DECISION_MILLIS);
|
||||
snapshot = driver.snapshot();
|
||||
}
|
||||
if (budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
|| !snapshot.playerAlive()) {
|
||||
driver.setInput(false, false, false);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static void setInput(LevelPlaytestBot.Driver driver, LevelPlaytestBot.Input input) {
|
||||
if (input == LevelPlaytestBot.Input.right()) {
|
||||
driver.setInput(false, true, false);
|
||||
} else if (input == LevelPlaytestBot.Input.left()) {
|
||||
driver.setInput(true, false, false);
|
||||
} else if (input == LevelPlaytestBot.Input.rightJump()) {
|
||||
driver.setInput(false, true, true);
|
||||
} else {
|
||||
driver.setInput(false, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean failed(GameView.AutomationSnapshot snapshot,
|
||||
PlaytestSimulationBudget budget,
|
||||
LevelPlaytestBot.Driver driver) {
|
||||
return !snapshot.completed()
|
||||
&& (!snapshot.playerAlive()
|
||||
|| budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()));
|
||||
}
|
||||
|
||||
private Result failure(String step, GameView.AutomationSnapshot snapshot,
|
||||
PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) {
|
||||
BotCommandTrace trace = finishTrace(driver, "FAILED", step, snapshot);
|
||||
return new Result(false, snapshot.deaths(), step + " failed; lastStep=" + lastStep
|
||||
+ "; " + budget.timingState(
|
||||
snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
+ "; " + state(snapshot), trace);
|
||||
}
|
||||
|
||||
private static void traceStep(LevelPlaytestBot.Driver driver, String step) {
|
||||
if (driver instanceof BotTraceDriver) ((BotTraceDriver) driver).step(step);
|
||||
}
|
||||
|
||||
private static BotCommandTrace finishTrace(LevelPlaytestBot.Driver driver, String outcome,
|
||||
String step,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
if (!(driver instanceof BotTraceDriver)) {
|
||||
throw new IllegalStateException("Level 8 scenario requires its tracing driver");
|
||||
}
|
||||
return ((BotTraceDriver) driver).finish(outcome, step, snapshot);
|
||||
}
|
||||
|
||||
private static String state(GameView.AutomationSnapshot snapshot) {
|
||||
return "x=" + snapshot.playerX()
|
||||
+ ", y=" + snapshot.playerY()
|
||||
+ ", alive=" + snapshot.playerAlive()
|
||||
+ ", doorOpen=" + snapshot.allDoorsOpen()
|
||||
+ ", completed=" + snapshot.completed()
|
||||
+ ", elapsedMs=" + snapshot.elapsedMilliseconds()
|
||||
+ ", deaths=" + snapshot.deaths()
|
||||
+ ", platformX=" + snapshot.firstMovingPlatformX()
|
||||
+ ", platformY=" + snapshot.firstMovingPlatformY()
|
||||
+ ", onMovingPlatform=" + snapshot.playerOnMovingPlatform()
|
||||
+ ", onGround=" + snapshot.playerOnGround()
|
||||
+ ", platformPowered=" + snapshot.firstMovingPlatformPowered()
|
||||
+ ", platformMovingRight=" + snapshot.firstMovingPlatformMovingRight();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import org.junit.runner.RunWith;
|
||||
/** Acceptance test: a bot must finish the same level implementation shipped in the APK. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class LevelFivePlaythroughTest {
|
||||
private static final int CONSECUTIVE_RUNS = 3;
|
||||
|
||||
@Test
|
||||
public void testBotCompletesLevelFive() {
|
||||
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
|
||||
@@ -19,15 +21,20 @@ public final class LevelFivePlaythroughTest {
|
||||
scenario.onActivity(activity -> view[0] = activity.gameViewForAutomation());
|
||||
|
||||
LevelPlaytestBot bot = new LevelPlaytestBot();
|
||||
LevelPlaytestBot.Result result = bot.proveLevelFive(
|
||||
new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]),
|
||||
25_000L
|
||||
);
|
||||
AndroidDriver driver = new AndroidDriver(
|
||||
InstrumentationRegistry.getInstrumentation(), view[0]);
|
||||
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
|
||||
LevelPlaytestBot.Result result = bot.proveLevelFive(driver, 25_000L);
|
||||
|
||||
org.junit.Assert.assertTrue(result.diagnostic(), result.completed());
|
||||
org.junit.Assert.assertTrue("The solution must use at least one volunteer", result.deaths() >= 1);
|
||||
org.junit.Assert.assertTrue("The delivered corpse must remain in the puzzle", result.corpseCount() >= 1);
|
||||
org.junit.Assert.assertTrue("The pressure-plate door must open", result.doorOpened());
|
||||
org.junit.Assert.assertTrue("run=" + run + "; " + result.diagnostic(),
|
||||
result.completed());
|
||||
org.junit.Assert.assertEquals("run=" + run + "; exactly one volunteer is required",
|
||||
1, result.deaths());
|
||||
org.junit.Assert.assertEquals("run=" + run + "; delivered corpse must remain",
|
||||
1, result.corpseCount());
|
||||
org.junit.Assert.assertTrue("run=" + run + "; pressure-plate door must open",
|
||||
result.doorOpened());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class OnboardingAndResultFlowTest {
|
||||
@Test
|
||||
public void tutorialIsOptionalNonBlockingAndDoesNotRepeatAfterRecreation() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.resetOnboardingForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
assertTrue(view.onboardingVisibleForAutomation());
|
||||
assertEquals(OnboardingFlow.Step.MOVEMENT,
|
||||
view.onboardingStepForAutomation());
|
||||
|
||||
view.setAutomationInput(false, true, false);
|
||||
assertTrue("Tutorial must not disable gameplay controls",
|
||||
view.automationSnapshot().inputActive());
|
||||
|
||||
view.advanceOnboardingForAutomation();
|
||||
view.advanceOnboardingForAutomation();
|
||||
view.advanceOnboardingForAutomation();
|
||||
assertFalse(view.onboardingVisibleForAutomation());
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
assertFalse(view.onboardingVisibleForAutomation());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completionShowsRunAndSavedBestAndAllResultRoutesPreserveState() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.selectNextControlSizeForAutomation();
|
||||
String selectedSize = view.controlSettingsForAutomation().controlSizeLabel();
|
||||
|
||||
view.startLevelForAutomation(1);
|
||||
view.finishLevelForAutomation(2, 42_000L);
|
||||
CompletionResult result = view.completionResultForAutomation();
|
||||
assertNotNull(result);
|
||||
assertTrue(view.resultVisibleForAutomation());
|
||||
assertEquals(2, result.currentDeaths());
|
||||
assertEquals(42_000L, result.currentTimeMs());
|
||||
assertTrue(result.bestDeaths() <= result.currentDeaths());
|
||||
assertTrue(result.bestTimeMs() <= result.currentTimeMs());
|
||||
assertTrue(view.levelCompletedForAutomation(1));
|
||||
|
||||
view.replayFromResultForAutomation();
|
||||
assertEquals(1, view.automationSnapshot().levelNumber());
|
||||
assertFalse(view.automationSnapshot().completed());
|
||||
assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertTrue(view.levelCompletedForAutomation(1));
|
||||
|
||||
view.finishLevelForAutomation(1, 35_000L);
|
||||
view.nextLevelFromResultForAutomation();
|
||||
assertEquals(2, view.automationSnapshot().levelNumber());
|
||||
assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertTrue(view.levelCompletedForAutomation(1));
|
||||
|
||||
view.finishLevelForAutomation(1, 35_000L);
|
||||
view.levelsFromResultForAutomation();
|
||||
assertTrue(view.levelsVisibleForAutomation());
|
||||
assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertTrue(view.levelCompletedForAutomation(2));
|
||||
view.resetControlSettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class OnboardingStoreTest {
|
||||
@Test
|
||||
public void dismissalSurvivesStoreRecreation() {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
OnboardingStore first = new OnboardingStore(context);
|
||||
first.resetForAutomation();
|
||||
assertFalse(first.isComplete());
|
||||
|
||||
first.markComplete();
|
||||
|
||||
assertTrue(new OnboardingStore(context).isComplete());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class StageThreeAcceptanceTest {
|
||||
@Test
|
||||
public void localizedAccessibleSettingsSurviveRecreationWithoutChangingProgress() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
int[] unlocked = new int[1];
|
||||
int[] bestDeaths = new int[1];
|
||||
long[] bestTime = new long[1];
|
||||
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.resetLanguageForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.toggleHapticsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
ProgressStore progress = new ProgressStore(owner);
|
||||
unlocked[0] = progress.unlockedCount();
|
||||
bestDeaths[0] = progress.bestDeaths(1);
|
||||
bestTime[0] = progress.bestTimeMs(1);
|
||||
});
|
||||
|
||||
tap(activity, 1_700f, 115f); // Settings.
|
||||
tap(activity, 1_700f, 115f); // RU.
|
||||
tap(activity, 220f, 345f); // Large text.
|
||||
tap(activity, 220f, 515f); // High contrast.
|
||||
tap(activity, 1_250f, 345f); // Large controls.
|
||||
tap(activity, 1_250f, 515f); // High control opacity.
|
||||
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
assertEquals(GameLanguage.RU, view.languageForAutomation());
|
||||
assertEquals(1.25f, view.textScaleForAutomation(), 0f);
|
||||
assertTrue(view.highContrastForAutomation());
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("ДОСТУПНОСТЬ"));
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("Крупный"));
|
||||
assertTrue(view.screenDescriptionForAutomation().contains("Настройки"));
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
assertEquals(GameLanguage.RU, view.languageForAutomation());
|
||||
assertEquals(1.25f, view.textScaleForAutomation(), 0f);
|
||||
assertTrue(view.highContrastForAutomation());
|
||||
assertEquals("Large", view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertEquals("High", view.controlSettingsForAutomation().controlOpacityLabel());
|
||||
assertFalse(view.controlSettingsForAutomation().hapticsEnabled());
|
||||
|
||||
ProgressStore progress = new ProgressStore(owner);
|
||||
assertEquals(unlocked[0], progress.unlockedCount());
|
||||
assertEquals(bestDeaths[0], progress.bestDeaths(1));
|
||||
assertEquals(bestTime[0], progress.bestTimeMs(1));
|
||||
|
||||
view.resetLanguageForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void russianTutorialAndResultRemainNonBlockingAndExposeEveryOutcome() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.setLanguageForAutomation(GameLanguage.RU);
|
||||
view.resetOnboardingForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
|
||||
assertTrue(view.onboardingVisibleForAutomation());
|
||||
assertContainsPrefix(view.visibleLocalizedTextForAutomation(), "ДВИЖЕНИЕ");
|
||||
view.setAutomationInput(false, true, false);
|
||||
assertTrue(view.automationSnapshot().inputActive());
|
||||
|
||||
view.advanceOnboardingForAutomation();
|
||||
assertContainsPrefix(view.visibleLocalizedTextForAutomation(),
|
||||
"ДВИЖЕНИЕ + ПРЫЖОК");
|
||||
view.advanceOnboardingForAutomation();
|
||||
assertContainsPrefix(view.visibleLocalizedTextForAutomation(),
|
||||
"СМЕРТЬ ОСТАВЛЯЕТ ИНСТРУМЕНТ");
|
||||
view.advanceOnboardingForAutomation();
|
||||
assertFalse(view.onboardingVisibleForAutomation());
|
||||
|
||||
view.finishLevelForAutomation(2, 42_000L);
|
||||
List<String> result = view.visibleLocalizedTextForAutomation();
|
||||
assertContainsPrefix(result, "Текущий результат");
|
||||
assertContainsPrefix(result, "Лучший результат");
|
||||
assertContainsPrefix(result, "Цели");
|
||||
assertContainsPrefix(result, "Следующая цель");
|
||||
assertTrue(view.screenDescriptionForAutomation().contains("Уровень завершён"));
|
||||
view.setLanguageForAutomation(GameLanguage.EN);
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
assertFalse(view.onboardingVisibleForAutomation());
|
||||
assertEquals(GameLanguage.EN, view.languageForAutomation());
|
||||
view.setAutomationInput(false, false, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertContainsPrefix(List<String> values, String prefix) {
|
||||
for (String value : values) {
|
||||
if (value.startsWith(prefix)) return;
|
||||
}
|
||||
throw new AssertionError("Missing prefix '" + prefix + "' in " + values);
|
||||
}
|
||||
|
||||
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
|
||||
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
|
||||
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
|
||||
long now = SystemClock.uptimeMillis();
|
||||
MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0);
|
||||
view.dispatchTouchEvent(down);
|
||||
down.recycle();
|
||||
MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0);
|
||||
view.dispatchTouchEvent(up);
|
||||
up.recycle();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
final class AccessibilityScreenDescription {
|
||||
private AccessibilityScreenDescription() {
|
||||
}
|
||||
|
||||
static String levelSelect(int unlocked, int total) {
|
||||
return "Level select. " + unlocked + " of " + total
|
||||
+ " unlocked. Choose an unlocked level or open Settings.";
|
||||
}
|
||||
|
||||
static String settings(ControlSettings controls, AccessibilitySettings accessibility) {
|
||||
return "Settings. Control size " + controls.controlSizeLabel()
|
||||
+ ". Control opacity " + controls.controlOpacityLabel()
|
||||
+ ". Vibration " + controls.hapticsLabel()
|
||||
+ ". Text size " + accessibility.textSizeLabel()
|
||||
+ ". High contrast " + accessibility.highContrastLabel()
|
||||
+ ". Back.";
|
||||
}
|
||||
|
||||
static String playing(LevelDefinition level, int deaths, String elapsed,
|
||||
String criticalState, boolean paused) {
|
||||
if (paused) {
|
||||
return "Paused on level " + level.number + ", " + level.title
|
||||
+ ". Resume or return to level select.";
|
||||
}
|
||||
return "Playing level " + level.number + ", " + level.title
|
||||
+ ". " + deaths + " volunteers. Time " + elapsed
|
||||
+ ". " + criticalState
|
||||
+ ". Move left, move right, jump, restart, pause.";
|
||||
}
|
||||
|
||||
static String complete(LevelDefinition level, int deaths, String elapsed,
|
||||
boolean hasNextLevel) {
|
||||
return "Level complete. " + level.title + ". " + deaths
|
||||
+ " volunteers. Time " + elapsed
|
||||
+ ". Level select. Replay."
|
||||
+ (hasNextLevel ? " Next level." : "");
|
||||
}
|
||||
|
||||
static String levelSelect(GameStrings strings, int unlocked, int total) {
|
||||
return strings.levelSelectDescription(unlocked, total);
|
||||
}
|
||||
|
||||
static String settings(GameStrings strings, ControlSettings controls,
|
||||
AccessibilitySettings accessibility) {
|
||||
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);
|
||||
}
|
||||
|
||||
static String complete(GameStrings strings, LevelDefinition level, int deaths,
|
||||
String elapsed, boolean hasNextLevel) {
|
||||
return strings.completeDescription(level.number, deaths, elapsed, hasNextLevel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
public final class AccessibilitySettings {
|
||||
private enum TextSize {
|
||||
STANDARD("Standard", 1f),
|
||||
LARGE("Large", 1.25f);
|
||||
|
||||
final String label;
|
||||
final float scale;
|
||||
|
||||
TextSize(String label, float scale) {
|
||||
this.label = label;
|
||||
this.scale = scale;
|
||||
}
|
||||
}
|
||||
|
||||
private final TextSize textSize;
|
||||
private final boolean highContrastEnabled;
|
||||
private final boolean reducedMotionEnabled;
|
||||
|
||||
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, 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, reducedMotionEnabled);
|
||||
}
|
||||
|
||||
public String textSizeLabel() {
|
||||
return textSize.label;
|
||||
}
|
||||
|
||||
public float textScale() {
|
||||
return textSize.scale;
|
||||
}
|
||||
|
||||
public float canvasTextSize(float baseSize) {
|
||||
return baseSize * textSize.scale;
|
||||
}
|
||||
|
||||
public AccessibilitySettings nextTextSize() {
|
||||
TextSize[] values = TextSize.values();
|
||||
return new AccessibilitySettings(values[(textSize.ordinal() + 1) % values.length],
|
||||
highContrastEnabled, reducedMotionEnabled);
|
||||
}
|
||||
|
||||
public boolean highContrastEnabled() {
|
||||
return highContrastEnabled;
|
||||
}
|
||||
|
||||
public String highContrastLabel() {
|
||||
return highContrastEnabled ? "On" : "Off";
|
||||
}
|
||||
|
||||
public AccessibilitySettings toggleHighContrast() {
|
||||
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() {
|
||||
return textSize.ordinal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
final class AccessibilitySettingsStore {
|
||||
private static final String FILE = "project_sacrifice_accessibility_settings_v1";
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
AccessibilitySettingsStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
AccessibilitySettings load() {
|
||||
AccessibilitySettings defaults = AccessibilitySettings.defaults();
|
||||
return AccessibilitySettings.restore(
|
||||
preferences.getInt("text_size", defaults.textSizeIndex()),
|
||||
preferences.getBoolean("high_contrast", defaults.highContrastEnabled()),
|
||||
preferences.getBoolean("reduced_motion", defaults.reducedMotionEnabled())
|
||||
);
|
||||
}
|
||||
|
||||
void save(AccessibilitySettings settings) {
|
||||
preferences.edit()
|
||||
.putInt("text_size", settings.textSizeIndex())
|
||||
.putBoolean("high_contrast", settings.highContrastEnabled())
|
||||
.putBoolean("reduced_motion", settings.reducedMotionEnabled())
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Allocation-free critical-state comparison after a level has been configured. */
|
||||
final class AccessibilityStateCache {
|
||||
private List<String> signalIds = Collections.emptyList();
|
||||
private GameLanguage language;
|
||||
private int activeSignals = Integer.MIN_VALUE;
|
||||
private int openDoors = Integer.MIN_VALUE;
|
||||
private int totalDoors = Integer.MIN_VALUE;
|
||||
private boolean playerAlive;
|
||||
private String statusText;
|
||||
|
||||
void configureSignals(List<String> sourceIds) {
|
||||
ArrayList<String> unique = new ArrayList<>();
|
||||
for (String signalId : sourceIds) {
|
||||
if (!unique.contains(signalId)) unique.add(signalId);
|
||||
}
|
||||
signalIds = Collections.unmodifiableList(unique);
|
||||
language = null;
|
||||
activeSignals = Integer.MIN_VALUE;
|
||||
openDoors = Integer.MIN_VALUE;
|
||||
totalDoors = Integer.MIN_VALUE;
|
||||
statusText = null;
|
||||
}
|
||||
|
||||
List<String> signalIds() {
|
||||
return signalIds;
|
||||
}
|
||||
|
||||
boolean update(GameStrings strings, int updatedActiveSignals, int updatedOpenDoors,
|
||||
int updatedTotalDoors, boolean updatedPlayerAlive) {
|
||||
GameLanguage updatedLanguage = strings.language();
|
||||
if (statusText != null
|
||||
&& language == updatedLanguage
|
||||
&& activeSignals == updatedActiveSignals
|
||||
&& openDoors == updatedOpenDoors
|
||||
&& totalDoors == updatedTotalDoors
|
||||
&& playerAlive == updatedPlayerAlive) {
|
||||
return false;
|
||||
}
|
||||
language = updatedLanguage;
|
||||
activeSignals = updatedActiveSignals;
|
||||
openDoors = updatedOpenDoors;
|
||||
totalDoors = updatedTotalDoors;
|
||||
playerAlive = updatedPlayerAlive;
|
||||
statusText = AccessibilityStatus.describe(strings, activeSignals, signalIds.size(),
|
||||
openDoors, totalDoors, playerAlive);
|
||||
return true;
|
||||
}
|
||||
|
||||
String statusText() {
|
||||
return statusText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
final class AccessibilityStatus {
|
||||
private AccessibilityStatus() {
|
||||
}
|
||||
|
||||
static String describe(int activeSignals, int totalSignals, int openDoors, int totalDoors,
|
||||
boolean playerAlive) {
|
||||
return "Signals " + activeSignals + "/" + totalSignals
|
||||
+ " · Doors " + openDoors + "/" + totalDoors
|
||||
+ " · Volunteer " + (playerAlive ? "active" : "respawning");
|
||||
}
|
||||
|
||||
static String describe(GameStrings strings, int activeSignals, int totalSignals,
|
||||
int openDoors, int totalDoors, boolean playerAlive) {
|
||||
return strings.accessibilityStatus(activeSignals, totalSignals, openDoors, totalDoors,
|
||||
playerAlive);
|
||||
}
|
||||
}
|
||||
@@ -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,195 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Immutable, portable journal of public bot inputs and observable checkpoints. */
|
||||
final class BotCommandTrace {
|
||||
static final int TRACE_VERSION = 1;
|
||||
|
||||
private final String json;
|
||||
private final String id;
|
||||
|
||||
private BotCommandTrace(String json) {
|
||||
this.json = json;
|
||||
this.id = sha256(json);
|
||||
}
|
||||
|
||||
String json() { return json; }
|
||||
String id() { return id; }
|
||||
|
||||
static Builder builder(int levelNumber, String scenario,
|
||||
GameView.AutomationSnapshot initial) {
|
||||
return new Builder(levelNumber, scenario, initial);
|
||||
}
|
||||
|
||||
static final class Builder {
|
||||
private final int levelNumber;
|
||||
private final int levelFormatVersion;
|
||||
private final int physicsVersion;
|
||||
private final String scenario;
|
||||
private final long simulationStartedAt;
|
||||
private final List<Event> events = new ArrayList<>();
|
||||
private String lastInputKey;
|
||||
private String outcome = "RUNNING";
|
||||
|
||||
Builder(int levelNumber, String scenario, GameView.AutomationSnapshot initial) {
|
||||
this.levelNumber = levelNumber;
|
||||
LevelDefinition level = LevelCatalog.get(levelNumber - 1);
|
||||
this.levelFormatVersion = level.formatVersion;
|
||||
this.physicsVersion = level.physicsVersion;
|
||||
this.scenario = scenario == null ? "" : scenario;
|
||||
this.simulationStartedAt = initial.elapsedMilliseconds();
|
||||
events.add(Event.state(0L, "START", "", false, false, false, initial));
|
||||
}
|
||||
|
||||
void input(String step, boolean left, boolean right, boolean jump,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
String key = safe(step) + '|' + left + '|' + right + '|' + jump;
|
||||
if (key.equals(lastInputKey)) return;
|
||||
lastInputKey = key;
|
||||
events.add(Event.state(elapsed(snapshot), "INPUT", safe(step),
|
||||
left, right, jump, snapshot));
|
||||
}
|
||||
|
||||
void checkpoint(String step, GameView.AutomationSnapshot snapshot) {
|
||||
lastInputKey = null;
|
||||
events.add(Event.state(elapsed(snapshot), "CHECKPOINT", safe(step),
|
||||
false, false, false, snapshot));
|
||||
}
|
||||
|
||||
BotCommandTrace finish(String outcome, String step,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
this.outcome = safe(outcome);
|
||||
events.add(Event.state(elapsed(snapshot), "FINISH", safe(step),
|
||||
false, false, false, snapshot));
|
||||
return new BotCommandTrace(json());
|
||||
}
|
||||
|
||||
private long elapsed(GameView.AutomationSnapshot snapshot) {
|
||||
return Math.max(0L, snapshot.elapsedMilliseconds() - simulationStartedAt);
|
||||
}
|
||||
|
||||
private String json() {
|
||||
StringBuilder value = new StringBuilder();
|
||||
value.append('{')
|
||||
.append("\"traceVersion\":").append(TRACE_VERSION).append(',')
|
||||
.append("\"level\":").append(levelNumber).append(',')
|
||||
.append("\"levelFormatVersion\":").append(levelFormatVersion).append(',')
|
||||
.append("\"physicsVersion\":").append(physicsVersion).append(',')
|
||||
.append("\"scenario\":\"").append(escape(scenario)).append("\",")
|
||||
.append("\"outcome\":\"").append(escape(outcome)).append("\",")
|
||||
.append("\"events\":[");
|
||||
for (int i = 0; i < events.size(); i++) {
|
||||
if (i > 0) value.append(',');
|
||||
events.get(i).appendJson(value);
|
||||
}
|
||||
return value.append("]}\n").toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Event {
|
||||
final long simulationMs;
|
||||
final String kind;
|
||||
final String step;
|
||||
final boolean left;
|
||||
final boolean right;
|
||||
final boolean jump;
|
||||
final float x;
|
||||
final float y;
|
||||
final boolean alive;
|
||||
final boolean completed;
|
||||
final int deaths;
|
||||
final int corpses;
|
||||
final boolean allDoorsOpen;
|
||||
|
||||
private Event(long simulationMs, String kind, String step,
|
||||
boolean left, boolean right, boolean jump,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
this.simulationMs = simulationMs;
|
||||
this.kind = kind;
|
||||
this.step = step;
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.jump = jump;
|
||||
this.x = snapshot.playerX();
|
||||
this.y = snapshot.playerY();
|
||||
this.alive = snapshot.playerAlive();
|
||||
this.completed = snapshot.completed();
|
||||
this.deaths = snapshot.deaths();
|
||||
this.corpses = snapshot.corpseCount();
|
||||
this.allDoorsOpen = snapshot.allDoorsOpen();
|
||||
}
|
||||
|
||||
static Event state(long simulationMs, String kind, String step,
|
||||
boolean left, boolean right, boolean jump,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
return new Event(simulationMs, kind, step, left, right, jump, snapshot);
|
||||
}
|
||||
|
||||
void appendJson(StringBuilder value) {
|
||||
value.append('{')
|
||||
.append("\"simulationMs\":").append(simulationMs).append(',')
|
||||
.append("\"kind\":\"").append(escape(kind)).append("\",")
|
||||
.append("\"step\":\"").append(escape(step)).append("\",")
|
||||
.append("\"input\":{")
|
||||
.append("\"left\":").append(left).append(',')
|
||||
.append("\"right\":").append(right).append(',')
|
||||
.append("\"jump\":").append(jump).append("},")
|
||||
.append("\"state\":{")
|
||||
.append("\"x\":").append(number(x)).append(',')
|
||||
.append("\"y\":").append(number(y)).append(',')
|
||||
.append("\"alive\":").append(alive).append(',')
|
||||
.append("\"completed\":").append(completed).append(',')
|
||||
.append("\"deaths\":").append(deaths).append(',')
|
||||
.append("\"corpses\":").append(corpses).append(',')
|
||||
.append("\"allDoorsOpen\":").append(allDoorsOpen)
|
||||
.append("}}");
|
||||
}
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String number(float value) {
|
||||
if (!Float.isFinite(value)) return "null";
|
||||
if (value == (long) value) return Long.toString((long) value);
|
||||
return Float.toString(value);
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
switch (c) {
|
||||
case '"': result.append("\\\""); break;
|
||||
case '\\': result.append("\\\\"); break;
|
||||
case '\b': result.append("\\b"); break;
|
||||
case '\f': result.append("\\f"); break;
|
||||
case '\n': result.append("\\n"); break;
|
||||
case '\r': result.append("\\r"); break;
|
||||
case '\t': result.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) result.append(String.format("\\u%04x", (int) c));
|
||||
else result.append(c);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (byte item : digest) result.append(String.format("%02x", item & 0xff));
|
||||
return result.toString();
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,63 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Adds versioned command tracing to bespoke bots without changing their input boundary. */
|
||||
final class BotTraceDriver implements LevelPlaytestBot.Driver {
|
||||
private final LevelPlaytestBot.Driver delegate;
|
||||
private final String scenario;
|
||||
private BotCommandTrace.Builder trace;
|
||||
private GameView.AutomationSnapshot latest;
|
||||
private String step = "Start";
|
||||
|
||||
BotTraceDriver(LevelPlaytestBot.Driver delegate, String scenario) {
|
||||
if (delegate == null) throw new IllegalArgumentException("driver is required");
|
||||
this.delegate = delegate;
|
||||
this.scenario = scenario == null ? "" : scenario;
|
||||
}
|
||||
|
||||
void step(String value) {
|
||||
step = value == null ? "" : value;
|
||||
}
|
||||
|
||||
BotCommandTrace finish(String outcome, String finalStep,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
ensureStarted();
|
||||
latest = snapshot == null ? latest : snapshot;
|
||||
return trace.finish(outcome, finalStep, latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
delegate.startLevel(oneBasedLevelNumber);
|
||||
latest = delegate.snapshot();
|
||||
trace = BotCommandTrace.builder(oneBasedLevelNumber, scenario, latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
ensureStarted();
|
||||
delegate.setInput(moveLeft, moveRight, jump);
|
||||
trace.input(step, moveLeft, moveRight, jump, latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
latest = delegate.snapshot();
|
||||
return latest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
delegate.waitForNextDecision(millis);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long currentTimeMillis() {
|
||||
return delegate.currentTimeMillis();
|
||||
}
|
||||
|
||||
private void ensureStarted() {
|
||||
if (trace == null || latest == null) {
|
||||
throw new IllegalStateException("startLevel must be called before tracing input");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,67 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Immutable completion statistics used by rendering and device-level navigation tests. */
|
||||
public final class CompletionResult {
|
||||
public enum NextGoal {
|
||||
TARGET_DEATHS,
|
||||
TARGET_TIME,
|
||||
NEXT_LEVEL,
|
||||
PERSONAL_BEST
|
||||
}
|
||||
|
||||
private final int currentDeaths;
|
||||
private final long currentTimeMs;
|
||||
private final int bestDeaths;
|
||||
private final long bestTimeMs;
|
||||
private final int targetDeaths;
|
||||
private final long targetTimeMs;
|
||||
private final NextGoal nextGoal;
|
||||
private final long nextGoalValue;
|
||||
|
||||
private CompletionResult(int currentDeaths, long currentTimeMs,
|
||||
int bestDeaths, long bestTimeMs,
|
||||
int targetDeaths, long targetTimeMs,
|
||||
NextGoal nextGoal, long nextGoalValue) {
|
||||
this.currentDeaths = currentDeaths;
|
||||
this.currentTimeMs = currentTimeMs;
|
||||
this.bestDeaths = bestDeaths;
|
||||
this.bestTimeMs = bestTimeMs;
|
||||
this.targetDeaths = targetDeaths;
|
||||
this.targetTimeMs = targetTimeMs;
|
||||
this.nextGoal = nextGoal;
|
||||
this.nextGoalValue = nextGoalValue;
|
||||
}
|
||||
|
||||
public static CompletionResult create(LevelDefinition level,
|
||||
int currentDeaths, long currentTimeMs,
|
||||
int bestDeaths, long bestTimeMs,
|
||||
boolean hasNextLevel) {
|
||||
long targetTimeMs = level.targetSeconds * 1_000L;
|
||||
NextGoal goal;
|
||||
long goalValue;
|
||||
if (currentDeaths > level.targetDeaths) {
|
||||
goal = NextGoal.TARGET_DEATHS;
|
||||
goalValue = level.targetDeaths;
|
||||
} else if (currentTimeMs > targetTimeMs) {
|
||||
goal = NextGoal.TARGET_TIME;
|
||||
goalValue = targetTimeMs;
|
||||
} else if (hasNextLevel) {
|
||||
goal = NextGoal.NEXT_LEVEL;
|
||||
goalValue = level.number + 1L;
|
||||
} else {
|
||||
goal = NextGoal.PERSONAL_BEST;
|
||||
goalValue = 0L;
|
||||
}
|
||||
return new CompletionResult(currentDeaths, currentTimeMs, bestDeaths, bestTimeMs,
|
||||
level.targetDeaths, targetTimeMs, goal, goalValue);
|
||||
}
|
||||
|
||||
public int currentDeaths() { return currentDeaths; }
|
||||
public long currentTimeMs() { return currentTimeMs; }
|
||||
public int bestDeaths() { return bestDeaths; }
|
||||
public long bestTimeMs() { return bestTimeMs; }
|
||||
public int targetDeaths() { return targetDeaths; }
|
||||
public long targetTimeMs() { return targetTimeMs; }
|
||||
public NextGoal nextGoal() { return nextGoal; }
|
||||
public long nextGoalValue() { return nextGoalValue; }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
public final class ControlSettings {
|
||||
private enum Size {
|
||||
SMALL("Small", 0.85f),
|
||||
MEDIUM("Medium", 1f),
|
||||
LARGE("Large", 1.2f);
|
||||
|
||||
final String label;
|
||||
final float scale;
|
||||
|
||||
Size(String label, float scale) {
|
||||
this.label = label;
|
||||
this.scale = scale;
|
||||
}
|
||||
}
|
||||
|
||||
private enum Opacity {
|
||||
LOW("Low", 0.45f),
|
||||
MEDIUM("Medium", 0.65f),
|
||||
HIGH("High", 0.9f);
|
||||
|
||||
final String label;
|
||||
final float alpha;
|
||||
|
||||
Opacity(String label, float alpha) {
|
||||
this.label = label;
|
||||
this.alpha = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
private final Size size;
|
||||
private final Opacity opacity;
|
||||
private final boolean hapticsEnabled;
|
||||
|
||||
private ControlSettings(Size size, Opacity opacity, boolean hapticsEnabled) {
|
||||
this.size = size;
|
||||
this.opacity = opacity;
|
||||
this.hapticsEnabled = hapticsEnabled;
|
||||
}
|
||||
|
||||
public static ControlSettings defaults() {
|
||||
return new ControlSettings(Size.MEDIUM, Opacity.MEDIUM, true);
|
||||
}
|
||||
|
||||
static ControlSettings restore(int sizeIndex, int opacityIndex, boolean hapticsEnabled) {
|
||||
Size[] sizes = Size.values();
|
||||
Opacity[] opacities = Opacity.values();
|
||||
if (sizeIndex < 0 || sizeIndex >= sizes.length
|
||||
|| opacityIndex < 0 || opacityIndex >= opacities.length) {
|
||||
return defaults();
|
||||
}
|
||||
return new ControlSettings(sizes[sizeIndex], opacities[opacityIndex], hapticsEnabled);
|
||||
}
|
||||
|
||||
public String controlSizeLabel() {
|
||||
return size.label;
|
||||
}
|
||||
|
||||
public float controlScale() {
|
||||
return size.scale;
|
||||
}
|
||||
|
||||
public ControlSettings nextControlSize() {
|
||||
Size[] values = Size.values();
|
||||
return new ControlSettings(values[(size.ordinal() + 1) % values.length], opacity,
|
||||
hapticsEnabled);
|
||||
}
|
||||
|
||||
public String controlOpacityLabel() {
|
||||
return opacity.label;
|
||||
}
|
||||
|
||||
public float controlOpacity() {
|
||||
return opacity.alpha;
|
||||
}
|
||||
|
||||
public ControlSettings nextControlOpacity() {
|
||||
Opacity[] values = Opacity.values();
|
||||
return new ControlSettings(size, values[(opacity.ordinal() + 1) % values.length],
|
||||
hapticsEnabled);
|
||||
}
|
||||
|
||||
public boolean hapticsEnabled() {
|
||||
return hapticsEnabled;
|
||||
}
|
||||
|
||||
public String hapticsLabel() {
|
||||
return hapticsEnabled ? "On" : "Off";
|
||||
}
|
||||
|
||||
public ControlSettings toggleHaptics() {
|
||||
return new ControlSettings(size, opacity, !hapticsEnabled);
|
||||
}
|
||||
|
||||
int controlSizeIndex() {
|
||||
return size.ordinal();
|
||||
}
|
||||
|
||||
int controlOpacityIndex() {
|
||||
return opacity.ordinal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
final class ControlSettingsStore {
|
||||
private static final String FILE = "project_sacrifice_control_settings_v1";
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
ControlSettingsStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
ControlSettings load() {
|
||||
ControlSettings defaults = ControlSettings.defaults();
|
||||
return ControlSettings.restore(
|
||||
preferences.getInt("size", defaults.controlSizeIndex()),
|
||||
preferences.getInt("opacity", defaults.controlOpacityIndex()),
|
||||
preferences.getBoolean("haptics", defaults.hapticsEnabled())
|
||||
);
|
||||
}
|
||||
|
||||
void save(ControlSettings settings) {
|
||||
preferences.edit()
|
||||
.putInt("size", settings.controlSizeIndex())
|
||||
.putInt("opacity", settings.controlOpacityIndex())
|
||||
.putBoolean("haptics", settings.hapticsEnabled())
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
@@ -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,108 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Declarative intended-route scenarios for the first four teaching levels. */
|
||||
public final class EarlyLevelScenarios {
|
||||
private EarlyLevelScenarios() {}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelOne() {
|
||||
return LevelPlaytestBot.Scenario.builder(1, "Level 1: movement and jump")
|
||||
.step("Approach the first ledge", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 500f)
|
||||
.step("Jump over the first ledge", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 900f)
|
||||
.step("Release jump before the upper ledge", 1_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 950f)
|
||||
.step("Jump over the upper ledge", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 1_330f)
|
||||
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwo() {
|
||||
return LevelPlaytestBot.Scenario.builder(2, "Level 2: three-corpse bridge")
|
||||
.step("Place the first bridge body", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() >= 1)
|
||||
.step("Wait for the second volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
EarlyLevelScenarios::respawnedAtStart)
|
||||
.step("Approach the first bridge body", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 470f)
|
||||
.step("Jump to the middle of the spikes", 2_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
.step("Place the second bridge body", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.deaths() >= 2)
|
||||
.step("Wait for the third volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
EarlyLevelScenarios::respawnedAtStart)
|
||||
.step("Return to the corpse bridge", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 470f)
|
||||
.step("Jump to the far side of the spikes", 2_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 750f)
|
||||
.step("Step off the middle bridge body", 2_000L, LevelPlaytestBot.Input.left(),
|
||||
snapshot -> snapshot.playerX() <= 690f)
|
||||
.step("Place the third bridge body", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.deaths() >= 3)
|
||||
.step("Wait for the crossing volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
EarlyLevelScenarios::respawnedAtStart)
|
||||
.step("Approach the completed bridge", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 470f)
|
||||
.step("Land on the far bridge body", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerOnGround() && snapshot.playerX() >= 800f)
|
||||
.step("Jump from the bridge to safe floor", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 900f)
|
||||
.step("Release jump before the final ledge", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 1_030f)
|
||||
.step("Jump over the final ledge", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 1_450f)
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelThree() {
|
||||
return LevelPlaytestBot.Scenario.builder(3, "Level 3: saw corpse pressure signal")
|
||||
.step("Leave a volunteer at the saw", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() >= 1)
|
||||
.step("Prove the corpse opens the pressure door", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::allDoorsOpen)
|
||||
.step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
EarlyLevelScenarios::respawnedAtStart)
|
||||
.step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 360f))
|
||||
.step("Take off before the saw", 1_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 620f))
|
||||
.step("Clear the saw and plate body", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, snapshot ->
|
||||
snapshot.playerAlive() && snapshot.playerX() >= 700f))
|
||||
.step("Reach the relic through the open door", 5_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelFour() {
|
||||
return LevelPlaytestBot.Scenario.builder(4, "Level 4: toggle lift to gallery")
|
||||
.step("Cross the toggle once and activate the lift", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::firstMovingPlatformPowered)
|
||||
.step("Approach the powered moving lift", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.firstMovingPlatformPowered()
|
||||
&& snapshot.playerX() >= 800f)
|
||||
.step("Jump onto the moving lift", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 1_050f && snapshot.playerY() < 690f)
|
||||
.step("Ride the lift to its upper phase", 7_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerY() <= 330f)
|
||||
.step("Move to the lift's right edge", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 1_225f)
|
||||
.step("Jump from the lift to the upper gallery", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 1_610f && snapshot.playerY() <= 400f)
|
||||
.step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static boolean respawnedAtStart(GameView.AutomationSnapshot snapshot) {
|
||||
return snapshot.playerAlive() && snapshot.playerX() < 250f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Player-selected language, independent from the device locale. */
|
||||
public enum GameLanguage {
|
||||
EN("en"),
|
||||
RU("ru");
|
||||
|
||||
private final String code;
|
||||
|
||||
GameLanguage(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public GameLanguage next() {
|
||||
return this == EN ? RU : EN;
|
||||
}
|
||||
|
||||
public static GameLanguage fromCode(String code) {
|
||||
if (RU.code.equalsIgnoreCase(code)) return RU;
|
||||
return EN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/** Persists the explicit in-game language without mutating progress or controls. */
|
||||
final class GameLanguageStore {
|
||||
private static final String FILE = "project_sacrifice_language_v1";
|
||||
private static final String KEY_LANGUAGE = "language";
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
GameLanguageStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
GameLanguage load() {
|
||||
return GameLanguage.fromCode(preferences.getString(KEY_LANGUAGE, GameLanguage.EN.code()));
|
||||
}
|
||||
|
||||
void save(GameLanguage language) {
|
||||
preferences.edit().putString(KEY_LANGUAGE,
|
||||
language == null ? GameLanguage.EN.code() : language.code()).apply();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** All player-facing copy behind one typed, Android-independent interface. */
|
||||
public final class GameStrings {
|
||||
private final GameLanguage language;
|
||||
|
||||
private GameStrings(GameLanguage language) {
|
||||
this.language = language == null ? GameLanguage.EN : language;
|
||||
}
|
||||
|
||||
public static GameStrings forLanguage(GameLanguage language) {
|
||||
return new GameStrings(language);
|
||||
}
|
||||
|
||||
public GameLanguage language() {
|
||||
return language;
|
||||
}
|
||||
|
||||
private boolean russian() {
|
||||
return language == GameLanguage.RU;
|
||||
}
|
||||
|
||||
public String appContentDescription() {
|
||||
return russian()
|
||||
? "Project Sacrifice. Платформенная головоломка из десяти уровней."
|
||||
: "Project Sacrifice. Ten-level puzzle platform game.";
|
||||
}
|
||||
|
||||
public String projectTitle() { return "PROJECT SACRIFICE"; }
|
||||
public String tagline() {
|
||||
return russian() ? "Каждый конец оставляет путь вперёд"
|
||||
: "Every ending leaves a way forward";
|
||||
}
|
||||
public String locked() { return russian() ? "ЗАКРЫТО" : "LOCKED"; }
|
||||
public String tapToEnter() { return russian() ? "Нажмите, чтобы войти" : "Tap to enter"; }
|
||||
public String levelSelectFooter() {
|
||||
return russian()
|
||||
? "Альбомная ориентация · мультитач · прогресс сохраняется автоматически"
|
||||
: "Landscape · multitouch · progress saves automatically";
|
||||
}
|
||||
|
||||
public String settingsButton() {
|
||||
return russian() ? "Настройки" : "Settings";
|
||||
}
|
||||
|
||||
public String settingsTitle() { return russian() ? "НАСТРОЙКИ" : "SETTINGS"; }
|
||||
public String settingsInstruction() {
|
||||
return russian() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически."
|
||||
: "Tap a row to change it. Changes save automatically.";
|
||||
}
|
||||
public String controlSizeSetting() { return russian() ? "Размер управления" : "Control size"; }
|
||||
public String controlOpacitySetting() { return russian() ? "Прозрачность управления" : "Control opacity"; }
|
||||
public String vibrationSetting() { return russian() ? "Вибрация" : "Vibration"; }
|
||||
public String languageSetting() { return russian() ? "Язык" : "Language"; }
|
||||
public String languageName() { return russian() ? "Русский" : "English"; }
|
||||
public String controlSizeValue(int index) {
|
||||
String[] values = russian()
|
||||
? new String[]{"Маленький", "Средний", "Большой"}
|
||||
: new String[]{"Small", "Medium", "Large"};
|
||||
return values[validPreset(index)];
|
||||
}
|
||||
public String controlOpacityValue(int index) {
|
||||
String[] values = russian()
|
||||
? new String[]{"Низкая", "Средняя", "Высокая"}
|
||||
: new String[]{"Low", "Medium", "High"};
|
||||
return values[validPreset(index)];
|
||||
}
|
||||
public String hapticsValue(boolean enabled) {
|
||||
if (russian()) return enabled ? "Вкл." : "Выкл.";
|
||||
return enabled ? "On" : "Off";
|
||||
}
|
||||
public String accessibilityTitle() { return russian() ? "ДОСТУПНОСТЬ" : "ACCESSIBILITY"; }
|
||||
public String textSizeSetting() { return russian() ? "Размер текста" : "Text size"; }
|
||||
public String textSizeValue(float scale) {
|
||||
return scale > 1f ? (russian() ? "Крупный" : "Large")
|
||||
: (russian() ? "Обычный" : "Standard");
|
||||
}
|
||||
public String highContrastSetting() { return russian() ? "Контраст" : "High contrast"; }
|
||||
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) {
|
||||
if (russian()) {
|
||||
return "Добровольцы " + deaths + " Время " + GameMath.formatTime(elapsedMilliseconds);
|
||||
}
|
||||
return "Volunteers " + deaths + " Time " + GameMath.formatTime(elapsedMilliseconds);
|
||||
}
|
||||
public String restart() { return russian() ? "Заново" : "Restart"; }
|
||||
public String respawning() {
|
||||
return russian() ? "Следующий доброволец уже в пути…"
|
||||
: "Another volunteer is on the way…";
|
||||
}
|
||||
|
||||
public String levelBestStats(int deaths, long elapsedMilliseconds) {
|
||||
return "✓ " + deaths + " " + volunteerWord(deaths) + " · "
|
||||
+ GameMath.formatTime(elapsedMilliseconds);
|
||||
}
|
||||
public String relicClaimed() { return russian() ? "РЕЛИКВИЯ ПОЛУЧЕНА" : "RELIC CLAIMED"; }
|
||||
public String completion() { return russian() ? "Завершение ★" : "Completion ★"; }
|
||||
public String completionDeaths(boolean earned, int deaths, int targetDeaths) {
|
||||
return (earned ? "★" : "☆") + " " + deaths + " / " + targetDeaths + " "
|
||||
+ volunteerWord(deaths);
|
||||
}
|
||||
public String completionTime(boolean earned, long elapsedMilliseconds, long targetMilliseconds) {
|
||||
return (earned ? "★" : "☆") + " " + GameMath.formatTime(elapsedMilliseconds)
|
||||
+ " / " + GameMath.formatTime(targetMilliseconds);
|
||||
}
|
||||
public String levelSelect() { return russian() ? "Выбор уровня" : "Level select"; }
|
||||
public String replay() { return russian() ? "Повторить" : "Replay"; }
|
||||
public String nextLevel() { return russian() ? "Следующий уровень" : "Next level"; }
|
||||
public String paused() { return russian() ? "ПАУЗА" : "PAUSED"; }
|
||||
public String resume() { return russian() ? "Продолжить" : "Resume"; }
|
||||
|
||||
// Result-flow hooks used by the Stage 3 result screen.
|
||||
public String currentResult() { return russian() ? "Текущий результат" : "Current result"; }
|
||||
public String bestResult() { return russian() ? "Лучший результат" : "Best result"; }
|
||||
public String deathsStat() { return russian() ? "Добровольцы" : "Volunteers"; }
|
||||
public String timeStat() { return russian() ? "Время" : "Time"; }
|
||||
public String newBest() { return russian() ? "НОВЫЙ РЕКОРД" : "NEW BEST"; }
|
||||
|
||||
public String currentResultLine(int deaths, long timeMs) {
|
||||
return currentResult() + " · " + deaths + " " + volunteerWord(deaths)
|
||||
+ " · " + GameMath.formatTime(timeMs);
|
||||
}
|
||||
|
||||
public String bestResultLine(int deaths, long timeMs) {
|
||||
return bestResult() + " · " + deaths + " " + volunteerWord(deaths)
|
||||
+ " · " + GameMath.formatTime(timeMs);
|
||||
}
|
||||
|
||||
public String targetsResultLine(int deaths, long timeMs) {
|
||||
String label = russian() ? "Цели" : "Targets";
|
||||
return label + " · " + deaths + " " + volunteerWord(deaths)
|
||||
+ " · " + GameMath.formatTime(timeMs);
|
||||
}
|
||||
|
||||
public String nextGoalDeaths(long deaths) {
|
||||
return russian()
|
||||
? "Следующая цель · пройти с " + deaths + " добровольцами"
|
||||
: "Next goal · finish with " + deaths + " volunteers";
|
||||
}
|
||||
|
||||
public String nextGoalTime(long timeMs) {
|
||||
return (russian() ? "Следующая цель · пройти за " : "Next goal · finish in ")
|
||||
+ GameMath.formatTime(timeMs);
|
||||
}
|
||||
|
||||
public String nextGoalLevel(long level) {
|
||||
return (russian() ? "Следующая цель · перейти на уровень "
|
||||
: "Next goal · enter level ") + level;
|
||||
}
|
||||
|
||||
public String nextGoalPersonalBest() {
|
||||
return russian() ? "Следующая цель · улучшить личный рекорд"
|
||||
: "Next goal · improve your personal best";
|
||||
}
|
||||
|
||||
// Onboarding hooks keep new tutorial Canvas copy out of GameView conditionals.
|
||||
public String tutorialWelcomeTitle() {
|
||||
return russian() ? "Смерть — это инструмент" : "Death is a tool";
|
||||
}
|
||||
public String tutorialWelcomeBody() {
|
||||
return russian()
|
||||
? "Двигайтесь стрелками и прыгайте кнопкой с крылом. Доберитесь до реликвии."
|
||||
: "Move with the arrows and jump with the winged button. Reach the relic.";
|
||||
}
|
||||
public String tutorialSacrificeTitle() {
|
||||
return russian() ? "Оставьте путь вперёд" : "Leave a way forward";
|
||||
}
|
||||
public String tutorialSacrificeBody() {
|
||||
return russian()
|
||||
? "После смерти тело остаётся в мире: закрывает шипы, держит плиты и становится ступенью."
|
||||
: "A body stays in the world after death: cover spikes, hold plates, and make a step.";
|
||||
}
|
||||
public String tutorialSkip() { return russian() ? "Пропустить" : "Skip"; }
|
||||
public String tutorialNext() { return russian() ? "Далее" : "Next"; }
|
||||
public String tutorialStart() { return russian() ? "Начать" : "Start"; }
|
||||
public String tutorialDone() { return russian() ? "Готово" : "Done"; }
|
||||
public String tutorialMovementTitle() { return russian() ? "ДВИЖЕНИЕ" : "MOVE"; }
|
||||
public String tutorialMovementBody() {
|
||||
return russian() ? "Удерживайте левую или правую стрелку, чтобы двигаться."
|
||||
: "Hold the left or right arrow to explore.";
|
||||
}
|
||||
public String tutorialJumpTitle() { return russian() ? "ДВИЖЕНИЕ + ПРЫЖОК" : "MOVE + JUMP"; }
|
||||
public String tutorialJumpBody() {
|
||||
return russian() ? "Используйте два пальца: держите направление и нажмите Прыжок."
|
||||
: "Use two fingers: hold a direction and tap Jump.";
|
||||
}
|
||||
public String tutorialCorpseTitle() {
|
||||
return russian() ? "СМЕРТЬ ОСТАВЛЯЕТ ИНСТРУМЕНТ" : "DEATH LEAVES A TOOL";
|
||||
}
|
||||
public String tutorialCorpseBody() {
|
||||
return russian() ? "Павший доброволец остаётся. Используйте тело, чтобы открыть путь."
|
||||
: "A fallen volunteer remains. Use the body to open a path.";
|
||||
}
|
||||
public String tutorialProgress(int step, int total) {
|
||||
return step + " / " + total;
|
||||
}
|
||||
|
||||
public String accessibilityStatus(int activeSignals, int totalSignals,
|
||||
int openDoors, int totalDoors, boolean playerAlive) {
|
||||
if (russian()) {
|
||||
return "Сигналы " + activeSignals + "/" + totalSignals
|
||||
+ " · Двери " + openDoors + "/" + totalDoors
|
||||
+ " · Доброволец " + (playerAlive ? "активен" : "возрождается");
|
||||
}
|
||||
return "Signals " + activeSignals + "/" + totalSignals
|
||||
+ " · Doors " + openDoors + "/" + totalDoors
|
||||
+ " · Volunteer " + (playerAlive ? "active" : "respawning");
|
||||
}
|
||||
|
||||
public String levelSelectDescription(int unlocked, int total) {
|
||||
if (russian()) {
|
||||
return "Выбор уровня. Открыто " + unlocked + " из " + total
|
||||
+ ". Выберите открытый уровень или Настройки.";
|
||||
}
|
||||
return "Level select. " + unlocked + " of " + total
|
||||
+ " unlocked. Choose an unlocked level or open Settings.";
|
||||
}
|
||||
|
||||
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())
|
||||
+ ". Прозрачность управления "
|
||||
+ controlOpacityValue(controls.controlOpacityIndex())
|
||||
+ ". Вибрация " + 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()
|
||||
+ ". Control opacity " + controls.controlOpacityLabel()
|
||||
+ ". 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.";
|
||||
}
|
||||
|
||||
public String playingDescription(int levelNumber, int deaths, String elapsed,
|
||||
String criticalState, boolean paused) {
|
||||
if (paused) {
|
||||
return russian()
|
||||
? "Пауза на уровне " + levelNumber + ", " + levelTitle(levelNumber)
|
||||
+ ". Продолжить или вернуться к выбору уровня."
|
||||
: "Paused on level " + levelNumber + ", " + levelTitle(levelNumber)
|
||||
+ ". Resume or return to level select.";
|
||||
}
|
||||
if (russian()) {
|
||||
return "Идёт уровень " + levelNumber + ", " + levelTitle(levelNumber)
|
||||
+ ". Добровольцы " + deaths + ". Время " + elapsed + ". "
|
||||
+ criticalState + ". Движение влево и вправо, прыжок, заново, пауза.";
|
||||
}
|
||||
return "Playing level " + levelNumber + ", " + levelTitle(levelNumber)
|
||||
+ ". " + deaths + " volunteers. Time " + elapsed + ". " + criticalState
|
||||
+ ". Move left, move right, jump, restart, pause.";
|
||||
}
|
||||
|
||||
public String completeDescription(int levelNumber, int deaths, String elapsed,
|
||||
boolean hasNextLevel) {
|
||||
if (russian()) {
|
||||
return "Уровень завершён. " + levelTitle(levelNumber) + ". Добровольцы "
|
||||
+ deaths + ". Время " + elapsed
|
||||
+ ". Выбор уровня. Повторить."
|
||||
+ (hasNextLevel ? " Следующий уровень." : "");
|
||||
}
|
||||
return "Level complete. " + levelTitle(levelNumber) + ". " + deaths
|
||||
+ " volunteers. Time " + elapsed + ". Level select. Replay."
|
||||
+ (hasNextLevel ? " Next level." : "");
|
||||
}
|
||||
|
||||
public String levelTitle(int oneBasedLevelNumber) {
|
||||
LevelDefinition level = level(oneBasedLevelNumber);
|
||||
return russian() ? level.titleRu : level.title;
|
||||
}
|
||||
|
||||
public String levelHint(int oneBasedLevelNumber) {
|
||||
LevelDefinition level = level(oneBasedLevelNumber);
|
||||
return russian() ? level.hintRu : level.hint;
|
||||
}
|
||||
|
||||
private String volunteerWord(int count) {
|
||||
if (!russian()) return count == 1 ? "volunteer" : "volunteers";
|
||||
int mod100 = Math.abs(count) % 100;
|
||||
int mod10 = Math.abs(count) % 10;
|
||||
if (mod100 >= 11 && mod100 <= 14) return "добровольцев";
|
||||
if (mod10 == 1) return "доброволец";
|
||||
if (mod10 >= 2 && mod10 <= 4) return "добровольца";
|
||||
return "добровольцев";
|
||||
}
|
||||
|
||||
private static int validPreset(int index) {
|
||||
return index >= 0 && index <= 2 ? index : 1;
|
||||
}
|
||||
|
||||
private static LevelDefinition level(int oneBasedLevelNumber) {
|
||||
int index = oneBasedLevelNumber - 1;
|
||||
if (index < 0 || index >= LevelCatalog.all().size()) index = 0;
|
||||
return LevelCatalog.get(index);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,194 +0,0 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.ELECTRIC;
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.FREEZER;
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.PIT;
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.SAW;
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.SPIKES;
|
||||
import static com.projectsacrifice.game.LevelDefinition.SwitchType.PRESSURE;
|
||||
import static com.projectsacrifice.game.LevelDefinition.SwitchType.TIMED;
|
||||
import static com.projectsacrifice.game.LevelDefinition.SwitchType.TOGGLE;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Ten original vertical-slice puzzles with a one-mechanic-at-a-time learning curve. */
|
||||
public final class LevelCatalog {
|
||||
private static final float FLOOR_Y = 880f;
|
||||
private static final List<LevelDefinition> LEVELS = Collections.unmodifiableList(Arrays.asList(
|
||||
level1(), level2(), level3(), level4(), level5(),
|
||||
level6(), level7(), level8(), level9(), level10()
|
||||
));
|
||||
|
||||
private LevelCatalog() {}
|
||||
|
||||
public static List<LevelDefinition> all() { return LEVELS; }
|
||||
|
||||
public static LevelDefinition get(int index) {
|
||||
if (index < 0 || index >= LEVELS.size()) {
|
||||
throw new IllegalArgumentException("Unknown level index: " + index);
|
||||
}
|
||||
return LEVELS.get(index);
|
||||
}
|
||||
|
||||
private static LevelDefinition level1() {
|
||||
return level(
|
||||
1, "The Volunteer", "Reach the relic. Arrows move; the winged button jumps.",
|
||||
1920, 130, 760, goal(1690, 770), 0, 30,
|
||||
platforms(floor(0, 1920), ledge(640, 760, 230), ledge(1060, 690, 250)),
|
||||
hazards(), switches(), doors()
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level2() {
|
||||
return level(
|
||||
2, "A Useful End", "The spikes keep every volunteer. Build a path from what remains.",
|
||||
2050, 130, 760, goal(1830, 770), 3, 55,
|
||||
platforms(floor(0, 2050), ledge(1180, 740, 240)),
|
||||
hazards(hazard(SPIKES, 610, 850, 260, 30)), switches(), doors()
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level3() {
|
||||
return level(
|
||||
3, "Dead Weight", "Leave a body on the brass plate so the next volunteer can pass.",
|
||||
2050, 130, 760, goal(1810, 770), 1, 50,
|
||||
platforms(floor(0, 2050)),
|
||||
hazards(hazard(SAW, 565, 760, 92, 92, null, 0, -150)),
|
||||
switches(sw(PRESSURE, 540, 860, 150, 20, "gate", 0, 1)),
|
||||
doors(door(1110, 570, 78, 310, "gate"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level4() {
|
||||
return level(
|
||||
4, "The Long Lift", "Touch the lever, then ride the moving platform to the upper gallery.",
|
||||
2200, 130, 760, goal(1960, 390), 0, 60,
|
||||
platforms(
|
||||
floor(0, 1500), floor(1820, 380), ledge(1660, 500, 540),
|
||||
moving(1040, 790, 260, 34, 0, -390, 5.2f, "lift")
|
||||
),
|
||||
hazards(hazard(PIT, 1500, 880, 320, 200)),
|
||||
switches(sw(TOGGLE, 520, 820, 72, 60, "lift", 0, 1)), doors()
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level5() {
|
||||
return level(
|
||||
5, "Express Delivery", "The saw throws to the right. Deliver a volunteer to the distant plate.",
|
||||
2300, 130, 760, goal(2080, 770), 1, 65,
|
||||
platforms(floor(0, 2300), ledge(850, 800, 330)),
|
||||
hazards(hazard(SAW, 570, 760, 96, 96, null, 500, -700)),
|
||||
switches(sw(PRESSURE, 930, 780, 160, 20, "gate", 0, 1)),
|
||||
doors(door(1390, 560, 82, 320, "gate"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level6() {
|
||||
return level(
|
||||
6, "Cold Comfort", "The frost makes a solid block. Use the frozen volunteer as a step.",
|
||||
2150, 130, 760, goal(1880, 500), 1, 65,
|
||||
platforms(floor(0, 1120), ledge(810, 590, 520), ledge(1430, 610, 570)),
|
||||
hazards(hazard(FREEZER, 690, 750, 100, 130)), switches(), doors()
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level7() {
|
||||
return level(
|
||||
7, "Live Wire", "A fallen volunteer conducts the current and keeps the relay alive.",
|
||||
2150, 130, 760, goal(1930, 770), 1, 55,
|
||||
platforms(floor(0, 2150)),
|
||||
hazards(hazard(ELECTRIC, 650, 780, 110, 100, "power", 0, 0)),
|
||||
switches(), doors(door(1130, 560, 84, 320, "power"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level8() {
|
||||
return level(
|
||||
8, "Borrowed Time", "The blue switch lasts five seconds. Keep moving.",
|
||||
2450, 130, 760, goal(2220, 600), 0, 38,
|
||||
platforms(
|
||||
floor(0, 900), floor(1510, 940), ledge(1980, 710, 470),
|
||||
moving(990, 770, 240, 34, 380, 0, 4.0f, "clock")
|
||||
),
|
||||
hazards(hazard(PIT, 900, 880, 610, 200)),
|
||||
switches(sw(TIMED, 560, 810, 90, 70, "clock", 5.5f, 1)),
|
||||
doors(door(1770, 560, 82, 320, "clock"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level9() {
|
||||
return level(
|
||||
9, "Two Good Reasons", "The final gate needs weight and power at the same time.",
|
||||
2550, 130, 760, goal(2310, 770), 2, 80,
|
||||
platforms(floor(0, 2550)),
|
||||
hazards(
|
||||
hazard(SPIKES, 520, 850, 130, 30),
|
||||
hazard(ELECTRIC, 910, 780, 110, 100, "power", 0, 0)
|
||||
),
|
||||
switches(sw(PRESSURE, 510, 842, 160, 38, "weight", 0, 1)),
|
||||
doors(door(1510, 540, 86, 340, "weight", "power"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level10() {
|
||||
return level(
|
||||
10, "The Last Procession", "Launch, freeze, conduct, ascend. Every lesson opens the last relic.",
|
||||
3100, 130, 760, goal(2880, 330), 3, 120,
|
||||
platforms(
|
||||
floor(0, 1880), floor(2140, 960), ledge(2420, 440, 680),
|
||||
moving(1840, 790, 250, 34, 0, -410, 5.0f, "lift")
|
||||
),
|
||||
hazards(
|
||||
hazard(SAW, 520, 760, 96, 96, null, 700, -240),
|
||||
hazard(FREEZER, 1110, 750, 100, 130),
|
||||
hazard(ELECTRIC, 1510, 780, 110, 100, "power", 0, 0),
|
||||
hazard(PIT, 1880, 880, 260, 200)
|
||||
),
|
||||
switches(sw(PRESSURE, 860, 860, 160, 20, "lift", 0, 1)),
|
||||
doors(door(2310, 310, 82, 570, "power"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level(
|
||||
int number, String title, String hint, float width,
|
||||
float spawnX, float spawnY, LevelDefinition.Box goal,
|
||||
int targetDeaths, int targetSeconds,
|
||||
List<LevelDefinition.Platform> platforms,
|
||||
List<LevelDefinition.Hazard> hazards,
|
||||
List<LevelDefinition.Switch> switches,
|
||||
List<LevelDefinition.Door> doors
|
||||
) {
|
||||
return new LevelDefinition(number, title, hint, width, spawnX, spawnY, goal,
|
||||
targetDeaths, targetSeconds, platforms, hazards, switches, doors);
|
||||
}
|
||||
|
||||
private static LevelDefinition.Box box(float x, float y, float w, float h) {
|
||||
return new LevelDefinition.Box(x, y, w, h);
|
||||
}
|
||||
|
||||
private static LevelDefinition.Box goal(float x, float y) { return box(x, y, 86, 110); }
|
||||
private static LevelDefinition.Platform floor(float x, float w) { return new LevelDefinition.Platform(box(x, FLOOR_Y, w, 200)); }
|
||||
private static LevelDefinition.Platform ledge(float x, float y, float w) { return new LevelDefinition.Platform(box(x, y, w, 34)); }
|
||||
private static LevelDefinition.Platform moving(float x, float y, float w, float h, float tx, float ty, float period, String control) {
|
||||
return new LevelDefinition.Platform(box(x, y, w, h), tx, ty, period, control);
|
||||
}
|
||||
private static LevelDefinition.Hazard hazard(LevelDefinition.HazardType type, float x, float y, float w, float h) {
|
||||
return new LevelDefinition.Hazard(type, box(x, y, w, h));
|
||||
}
|
||||
private static LevelDefinition.Hazard hazard(LevelDefinition.HazardType type, float x, float y, float w, float h, String relay, float ix, float iy) {
|
||||
return new LevelDefinition.Hazard(type, box(x, y, w, h), relay, ix, iy);
|
||||
}
|
||||
private static LevelDefinition.Switch sw(LevelDefinition.SwitchType type, float x, float y, float w, float h, String id, float duration, int mass) {
|
||||
return new LevelDefinition.Switch(type, box(x, y, w, h), id, duration, mass);
|
||||
}
|
||||
private static LevelDefinition.Door door(float x, float y, float w, float h, String... ids) {
|
||||
return new LevelDefinition.Door(box(x, y, w, h), ids);
|
||||
}
|
||||
|
||||
@SafeVarargs private static List<LevelDefinition.Platform> platforms(LevelDefinition.Platform... values) { return Arrays.asList(values); }
|
||||
@SafeVarargs private static List<LevelDefinition.Hazard> hazards(LevelDefinition.Hazard... values) { return Arrays.asList(values); }
|
||||
@SafeVarargs private static List<LevelDefinition.Switch> switches(LevelDefinition.Switch... values) { return Arrays.asList(values); }
|
||||
@SafeVarargs private static List<LevelDefinition.Door> doors(LevelDefinition.Door... values) { return Arrays.asList(values); }
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import java.util.List;
|
||||
|
||||
/** Runs black-box acceptance scenarios through the same input and state boundary as touch play. */
|
||||
public final class LevelPlaytestBot {
|
||||
private static final long DECISION_MILLIS = 16L;
|
||||
private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L;
|
||||
private static final long WALL_WATCHDOG_MULTIPLIER = 10L;
|
||||
|
||||
public interface Driver {
|
||||
void startLevel(int oneBasedLevelNumber);
|
||||
void setInput(boolean moveLeft, boolean moveRight, boolean jump);
|
||||
@@ -44,6 +48,27 @@ public final class LevelPlaytestBot {
|
||||
boolean reached(GameView.AutomationSnapshot snapshot);
|
||||
}
|
||||
|
||||
public static SnapshotCondition groundedAliveAtOrBeyond(float minimumX) {
|
||||
return snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= minimumX;
|
||||
}
|
||||
|
||||
public static SnapshotCondition whileDeathsAtMost(
|
||||
int maximumDeaths, SnapshotCondition completion) {
|
||||
return snapshot -> {
|
||||
if (snapshot.deaths() > maximumDeaths) {
|
||||
throw new AssertionError("Route expected at most " + maximumDeaths
|
||||
+ " deaths but observed " + snapshot.deaths()
|
||||
+ "; x=" + snapshot.playerX()
|
||||
+ ", y=" + snapshot.playerY()
|
||||
+ ", alive=" + snapshot.playerAlive()
|
||||
+ ", grounded=" + snapshot.playerOnGround());
|
||||
}
|
||||
return completion.reached(snapshot);
|
||||
};
|
||||
}
|
||||
|
||||
public static final class Scenario {
|
||||
private final int levelNumber;
|
||||
private final String name;
|
||||
@@ -102,14 +127,18 @@ public final class LevelPlaytestBot {
|
||||
private final int corpseCount;
|
||||
private final String diagnostic;
|
||||
private final String lastStep;
|
||||
private final String traceJson;
|
||||
private final String traceId;
|
||||
|
||||
Result(boolean completed, boolean doorOpened, int deaths, int corpseCount,
|
||||
String diagnostic, String lastStep) {
|
||||
String diagnostic, String lastStep, BotCommandTrace trace) {
|
||||
this.completed = completed;
|
||||
this.doorOpened = doorOpened;
|
||||
this.deaths = deaths;
|
||||
this.corpseCount = corpseCount;
|
||||
this.diagnostic = diagnostic;
|
||||
this.traceJson = trace.json();
|
||||
this.traceId = trace.id();
|
||||
this.diagnostic = diagnostic + "; traceId=" + traceId;
|
||||
this.lastStep = lastStep;
|
||||
}
|
||||
|
||||
@@ -119,50 +148,106 @@ public final class LevelPlaytestBot {
|
||||
public int corpseCount() { return corpseCount; }
|
||||
public String diagnostic() { return diagnostic; }
|
||||
public String lastStep() { return lastStep; }
|
||||
public String traceJson() { return traceJson; }
|
||||
public String traceId() { return traceId; }
|
||||
}
|
||||
|
||||
public Result run(Driver driver, Scenario scenario, long timeoutMillis) {
|
||||
driver.startLevel(scenario.levelNumber);
|
||||
long scenarioStartedAt = driver.currentTimeMillis();
|
||||
long stepStartedAt = scenarioStartedAt;
|
||||
long scenarioStartedAtWall = driver.currentTimeMillis();
|
||||
long wallWatchdogMillis = wallWatchdogMillis(timeoutMillis);
|
||||
int stepIndex = 0;
|
||||
String lastStep = scenario.steps.isEmpty() ? null : scenario.steps.get(0).name;
|
||||
boolean doorOpened = false;
|
||||
GameView.AutomationSnapshot last = driver.snapshot();
|
||||
BotCommandTrace.Builder trace = BotCommandTrace.builder(
|
||||
scenario.levelNumber, scenario.name, last);
|
||||
long scenarioStartedAtSimulation = last.elapsedMilliseconds();
|
||||
long stepStartedAtSimulation = scenarioStartedAtSimulation;
|
||||
|
||||
while (driver.currentTimeMillis() - scenarioStartedAt < timeoutMillis) {
|
||||
while (true) {
|
||||
last = driver.snapshot();
|
||||
long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall);
|
||||
long simulationElapsed = elapsed(
|
||||
last.elapsedMilliseconds(), scenarioStartedAtSimulation);
|
||||
doorOpened |= last.allDoorsOpen();
|
||||
if (last.completed()) {
|
||||
driver.setInput(false, false, false);
|
||||
applyInput(driver, trace, Input.none(), lastStep, last);
|
||||
BotCommandTrace finishedTrace = trace.finish("COMPLETED", lastStep, last);
|
||||
return new Result(true, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " completed; step=" + lastStep + "; " + snapshotState(last),
|
||||
lastStep);
|
||||
scenario.name + " completed; step=" + lastStep + "; "
|
||||
+ timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
lastStep, finishedTrace);
|
||||
}
|
||||
if (wallElapsed >= wallWatchdogMillis) {
|
||||
applyInput(driver, trace, Input.none(), lastStep, last);
|
||||
BotCommandTrace finishedTrace = trace.finish("WALL_WATCHDOG", lastStep, last);
|
||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " stopped by wall-clock watchdog; step=" + lastStep
|
||||
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
lastStep, finishedTrace);
|
||||
}
|
||||
if (simulationElapsed >= timeoutMillis) break;
|
||||
if (stepIndex >= scenario.steps.size()) break;
|
||||
|
||||
Step step = scenario.steps.get(stepIndex);
|
||||
lastStep = step.name;
|
||||
if (step.completion.reached(last)) {
|
||||
trace.checkpoint(step.name, last);
|
||||
applyInput(driver, trace, Input.none(), step.name, last);
|
||||
stepIndex++;
|
||||
stepStartedAt = driver.currentTimeMillis();
|
||||
stepStartedAtSimulation = last.elapsedMilliseconds();
|
||||
continue;
|
||||
}
|
||||
if (driver.currentTimeMillis() - stepStartedAt >= step.timeoutMillis) {
|
||||
driver.setInput(false, false, false);
|
||||
if (elapsed(last.elapsedMilliseconds(), stepStartedAtSimulation)
|
||||
>= step.timeoutMillis) {
|
||||
applyInput(driver, trace, Input.none(), step.name, last);
|
||||
BotCommandTrace finishedTrace = trace.finish("STEP_TIMEOUT", step.name, last);
|
||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " timed out; step=" + step.name + "; " + snapshotState(last),
|
||||
step.name);
|
||||
scenario.name + " timed out in simulation; step=" + step.name
|
||||
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
step.name, finishedTrace);
|
||||
}
|
||||
|
||||
driver.setInput(step.input.moveLeft, step.input.moveRight, step.input.jump);
|
||||
driver.waitForNextDecision(32L);
|
||||
applyInput(driver, trace, step.input, step.name, last);
|
||||
driver.waitForNextDecision(DECISION_MILLIS);
|
||||
}
|
||||
|
||||
driver.setInput(false, false, false);
|
||||
applyInput(driver, trace, Input.none(), lastStep, last);
|
||||
long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall);
|
||||
long simulationElapsed = elapsed(
|
||||
last.elapsedMilliseconds(), scenarioStartedAtSimulation);
|
||||
BotCommandTrace finishedTrace = trace.finish("SIMULATION_BUDGET", lastStep, last);
|
||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " did not complete; step=" + lastStep + "; " + snapshotState(last),
|
||||
lastStep);
|
||||
scenario.name + " exhausted its simulation budget; step=" + lastStep
|
||||
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
lastStep, finishedTrace);
|
||||
}
|
||||
|
||||
private static void applyInput(Driver driver, BotCommandTrace.Builder trace, Input input,
|
||||
String step, GameView.AutomationSnapshot snapshot) {
|
||||
driver.setInput(input.moveLeft, input.moveRight, input.jump);
|
||||
trace.input(step, input.moveLeft, input.moveRight, input.jump, snapshot);
|
||||
}
|
||||
|
||||
private static long wallWatchdogMillis(long simulationBudgetMillis) {
|
||||
if (simulationBudgetMillis > Long.MAX_VALUE / WALL_WATCHDOG_MULTIPLIER) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
return Math.max(MIN_WALL_WATCHDOG_MILLIS,
|
||||
simulationBudgetMillis * WALL_WATCHDOG_MULTIPLIER);
|
||||
}
|
||||
|
||||
private static long elapsed(long current, long startedAt) {
|
||||
return Math.max(0L, current - startedAt);
|
||||
}
|
||||
|
||||
private static String timingState(long simulationElapsed, long wallElapsed) {
|
||||
return "simulationElapsed=" + simulationElapsed + ", wallElapsed=" + wallElapsed;
|
||||
}
|
||||
|
||||
private static String snapshotState(GameView.AutomationSnapshot snapshot) {
|
||||
@@ -189,8 +274,10 @@ public final class LevelPlaytestBot {
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the saw", 4_000L, Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 390f)
|
||||
.step("Jump over the saw", 2_000L, Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 430f)
|
||||
.step("Take off before the saw", 1_000L, Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f)
|
||||
.step("Clear the saw", 2_000L, Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
.step("Run to the ledge", 4_000L, Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 750f)
|
||||
.step("Jump onto the ledge", 2_000L, Input.rightJump(),
|
||||
|
||||
@@ -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,19 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Stable public-state gate for a single moving-platform launch. */
|
||||
final class MovingPlatformLaunchPolicy {
|
||||
private MovingPlatformLaunchPolicy() {}
|
||||
|
||||
static boolean readyToBoard(GameView.AutomationSnapshot snapshot, float maximumPlatformX) {
|
||||
return snapshot.firstMovingPlatformPowered()
|
||||
&& snapshot.firstMovingPlatformMovingRight()
|
||||
&& snapshot.firstMovingPlatformX() <= maximumPlatformX;
|
||||
}
|
||||
|
||||
static boolean ready(GameView.AutomationSnapshot snapshot, float minimumPlatformX) {
|
||||
return snapshot.playerOnMovingPlatform()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.firstMovingPlatformPowered()
|
||||
&& snapshot.firstMovingPlatformX() >= minimumPlatformX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Small state machine for the optional, first-run gameplay hints. */
|
||||
public final class OnboardingFlow {
|
||||
public enum Step {
|
||||
MOVEMENT,
|
||||
MULTITOUCH_JUMP,
|
||||
CORPSES_ARE_TOOLS
|
||||
}
|
||||
|
||||
private Step step;
|
||||
private boolean complete;
|
||||
|
||||
private OnboardingFlow(boolean complete) {
|
||||
this.complete = complete;
|
||||
step = Step.MOVEMENT;
|
||||
}
|
||||
|
||||
public static OnboardingFlow firstRun(boolean alreadyComplete) {
|
||||
return new OnboardingFlow(alreadyComplete);
|
||||
}
|
||||
|
||||
public boolean isVisible() {
|
||||
return !complete;
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return complete;
|
||||
}
|
||||
|
||||
public Step step() {
|
||||
return step;
|
||||
}
|
||||
|
||||
public void next() {
|
||||
if (complete) return;
|
||||
if (step == Step.MOVEMENT) step = Step.MULTITOUCH_JUMP;
|
||||
else if (step == Step.MULTITOUCH_JUMP) step = Step.CORPSES_ARE_TOOLS;
|
||||
else complete = true;
|
||||
}
|
||||
|
||||
public void skip() {
|
||||
complete = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/** Versioned preference boundary for the optional first-run tutorial. */
|
||||
public final class OnboardingStore {
|
||||
private static final String FILE = "project_sacrifice_onboarding_v1";
|
||||
private static final String KEY_COMPLETE = "complete";
|
||||
private static final String KEY_SCHEMA = "schema";
|
||||
private static final int SCHEMA_VERSION = 1;
|
||||
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
public OnboardingStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return preferences.getBoolean(KEY_COMPLETE, false);
|
||||
}
|
||||
|
||||
public void markComplete() {
|
||||
preferences.edit()
|
||||
.putInt(KEY_SCHEMA, SCHEMA_VERSION)
|
||||
.putBoolean(KEY_COMPLETE, true)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public void resetForAutomation() {
|
||||
preferences.edit().clear().commit();
|
||||
}
|
||||
}
|
||||
@@ -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,56 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Simulation-first timeout with a separate watchdog for a stalled device. */
|
||||
final class PlaytestSimulationBudget {
|
||||
private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L;
|
||||
private static final long WALL_WATCHDOG_MULTIPLIER = 10L;
|
||||
|
||||
private final long simulationStartedAt;
|
||||
private final long wallStartedAt;
|
||||
private final long simulationBudgetMillis;
|
||||
private final long wallWatchdogMillis;
|
||||
|
||||
private PlaytestSimulationBudget(long simulationStartedAt, long wallStartedAt,
|
||||
long simulationBudgetMillis) {
|
||||
this.simulationStartedAt = simulationStartedAt;
|
||||
this.wallStartedAt = wallStartedAt;
|
||||
this.simulationBudgetMillis = simulationBudgetMillis;
|
||||
this.wallWatchdogMillis = wallWatchdogMillis(simulationBudgetMillis);
|
||||
}
|
||||
|
||||
static PlaytestSimulationBudget start(long simulationStartedAt, long wallStartedAt,
|
||||
long simulationBudgetMillis) {
|
||||
return new PlaytestSimulationBudget(
|
||||
simulationStartedAt, wallStartedAt, simulationBudgetMillis);
|
||||
}
|
||||
|
||||
boolean exhausted(long simulationNow, long wallNow) {
|
||||
return simulationElapsed(simulationNow) >= simulationBudgetMillis
|
||||
|| wallElapsed(wallNow) >= wallWatchdogMillis;
|
||||
}
|
||||
|
||||
String timingState(long simulationNow, long wallNow) {
|
||||
return "simulationElapsed=" + simulationElapsed(simulationNow)
|
||||
+ ", wallElapsed=" + wallElapsed(wallNow);
|
||||
}
|
||||
|
||||
private long simulationElapsed(long simulationNow) {
|
||||
return elapsed(simulationNow, simulationStartedAt);
|
||||
}
|
||||
|
||||
private long wallElapsed(long wallNow) {
|
||||
return elapsed(wallNow, wallStartedAt);
|
||||
}
|
||||
|
||||
private static long wallWatchdogMillis(long simulationBudgetMillis) {
|
||||
if (simulationBudgetMillis > Long.MAX_VALUE / WALL_WATCHDOG_MULTIPLIER) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
return Math.max(MIN_WALL_WATCHDOG_MILLIS,
|
||||
simulationBudgetMillis * WALL_WATCHDOG_MULTIPLIER);
|
||||
}
|
||||
|
||||
private static long elapsed(long current, long startedAt) {
|
||||
return Math.max(0L, current - startedAt);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AccessibilityScreenDescriptionTest {
|
||||
@Test
|
||||
public void everyPlayerFacingScreenNamesItsStateAndAvailableActions() {
|
||||
String levels = AccessibilityScreenDescription.levelSelect(3, 10);
|
||||
assertTrue(levels.contains("Level select"));
|
||||
assertTrue(levels.contains("3 of 10 unlocked"));
|
||||
assertTrue(levels.contains("Settings"));
|
||||
|
||||
String settings = AccessibilityScreenDescription.settings(
|
||||
ControlSettings.defaults(), AccessibilitySettings.defaults());
|
||||
assertTrue(settings.contains("Settings"));
|
||||
assertTrue(settings.contains("Text size Standard"));
|
||||
assertTrue(settings.contains("High contrast Off"));
|
||||
assertTrue(settings.contains("Back"));
|
||||
|
||||
String playing = AccessibilityScreenDescription.playing(
|
||||
LevelCatalog.get(0), 2, "01:23", "Signals 1/1", false);
|
||||
assertTrue(playing.contains("Playing level 1"));
|
||||
assertTrue(playing.contains("2 volunteers"));
|
||||
assertTrue(playing.contains("Signals 1/1"));
|
||||
assertTrue(playing.contains("Move left, move right, jump, restart, pause"));
|
||||
|
||||
String complete = AccessibilityScreenDescription.complete(
|
||||
LevelCatalog.get(0), 2, "01:23", true);
|
||||
assertTrue(complete.contains("Level complete"));
|
||||
assertTrue(complete.contains("Replay"));
|
||||
assertTrue(complete.contains("Next level"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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 AccessibilitySettingsTest {
|
||||
@Test
|
||||
public void textSizeCyclesBetweenReadablePresetsAndWraps() {
|
||||
AccessibilitySettings settings = AccessibilitySettings.defaults();
|
||||
|
||||
assertEquals("Standard", settings.textSizeLabel());
|
||||
assertEquals(1f, settings.textScale(), 0f);
|
||||
|
||||
settings = settings.nextTextSize();
|
||||
assertEquals("Large", settings.textSizeLabel());
|
||||
assertEquals(1.25f, settings.textScale(), 0f);
|
||||
|
||||
assertEquals("Standard", settings.nextTextSize().textSizeLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void highContrastIsExplicitAndReversible() {
|
||||
AccessibilitySettings settings = AccessibilitySettings.defaults();
|
||||
|
||||
assertFalse(settings.highContrastEnabled());
|
||||
assertEquals("Off", settings.highContrastLabel());
|
||||
|
||||
settings = settings.toggleHighContrast();
|
||||
assertTrue(settings.highContrastEnabled());
|
||||
assertEquals("On", settings.highContrastLabel());
|
||||
assertFalse(settings.toggleHighContrast().highContrastEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidPersistedPresetFallsBackWithoutChangingContrastChoice() {
|
||||
AccessibilitySettings settings = AccessibilitySettings.restore(99, true);
|
||||
|
||||
assertEquals("Standard", settings.textSizeLabel());
|
||||
assertTrue(settings.highContrastEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void largePresetScalesEveryCanvasTextRequestWithoutMovingLayout() {
|
||||
AccessibilitySettings large = AccessibilitySettings.defaults().nextTextSize();
|
||||
|
||||
assertEquals(30f, large.canvasTextSize(24f), 0f);
|
||||
assertEquals(80f, large.canvasTextSize(64f), 0f);
|
||||
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,47 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AccessibilityStateCacheTest {
|
||||
@Test
|
||||
public void unchangedFramesReuseOneStatusAndOneDeduplicatedSignalSet() {
|
||||
AccessibilityStateCache cache = new AccessibilityStateCache();
|
||||
cache.configureSignals(Arrays.asList("lift", "lift", "door"));
|
||||
|
||||
assertEquals(Arrays.asList("lift", "door"), cache.signalIds());
|
||||
assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true));
|
||||
String first = cache.statusText();
|
||||
|
||||
for (int frame = 0; frame < 600; frame++) {
|
||||
assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true));
|
||||
assertSame(first, cache.statusText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void criticalStateAndLanguageChangesInvalidateExactlyOnce() {
|
||||
AccessibilityStateCache cache = new AccessibilityStateCache();
|
||||
cache.configureSignals(Arrays.asList("plate"));
|
||||
cache.update(GameStrings.forLanguage(GameLanguage.EN), 0, 0, 1, true);
|
||||
String initial = cache.statusText();
|
||||
|
||||
assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false));
|
||||
String changed = cache.statusText();
|
||||
assertNotSame(initial, changed);
|
||||
assertTrue(changed.contains("Signals 1/1"));
|
||||
assertTrue(changed.contains("Volunteer respawning"));
|
||||
assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false));
|
||||
|
||||
assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false));
|
||||
assertTrue(cache.statusText().contains("Сигналы 1/1"));
|
||||
assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AccessibilityStatusTest {
|
||||
@Test
|
||||
public void criticalGameplayStateIsRepresentedByTextNotOnlyColorOrVibration() {
|
||||
assertEquals(
|
||||
"Signals 1/2 · Doors 0/1 · Volunteer respawning",
|
||||
AccessibilityStatus.describe(1, 2, 0, 1, false)
|
||||
);
|
||||
assertEquals(
|
||||
"Signals 0/0 · Doors 0/0 · Volunteer active",
|
||||
AccessibilityStatus.describe(0, 0, 0, 0, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,48 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public final class CompletionResultTest {
|
||||
@Test
|
||||
public void resultKeepsThisRunAndSavedBestSeparateAndSuggestsTheMissingTarget() {
|
||||
LevelDefinition level = LevelCatalog.get(0);
|
||||
|
||||
CompletionResult result = CompletionResult.create(
|
||||
level,
|
||||
2,
|
||||
42_000L,
|
||||
0,
|
||||
25_000L,
|
||||
true
|
||||
);
|
||||
|
||||
assertEquals(2, result.currentDeaths());
|
||||
assertEquals(42_000L, result.currentTimeMs());
|
||||
assertEquals(0, result.bestDeaths());
|
||||
assertEquals(25_000L, result.bestTimeMs());
|
||||
assertEquals(CompletionResult.NextGoal.TARGET_DEATHS, result.nextGoal());
|
||||
assertEquals(0, result.nextGoalValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masteredLevelPointsAtTheNextLevelOrPersonalBest() {
|
||||
LevelDefinition first = LevelCatalog.get(0);
|
||||
CompletionResult timeTarget = CompletionResult.create(first, 0, 31_000L,
|
||||
0, 25_000L, true);
|
||||
assertEquals(CompletionResult.NextGoal.TARGET_TIME, timeTarget.nextGoal());
|
||||
assertEquals(30_000L, timeTarget.nextGoalValue());
|
||||
|
||||
CompletionResult next = CompletionResult.create(first, 0, 25_000L,
|
||||
0, 25_000L, true);
|
||||
assertEquals(CompletionResult.NextGoal.NEXT_LEVEL, next.nextGoal());
|
||||
assertEquals(2, next.nextGoalValue());
|
||||
|
||||
LevelDefinition last = LevelCatalog.get(LevelCatalog.all().size() - 1);
|
||||
CompletionResult lastResult = CompletionResult.create(last,
|
||||
last.targetDeaths, last.targetSeconds * 900L,
|
||||
last.targetDeaths, last.targetSeconds * 900L, false);
|
||||
assertEquals(CompletionResult.NextGoal.PERSONAL_BEST, lastResult.nextGoal());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class ControlSettingsTest {
|
||||
@Test
|
||||
public void controlSizeCyclesThroughAccessiblePresetsAndWraps() {
|
||||
ControlSettings settings = ControlSettings.defaults();
|
||||
|
||||
assertEquals("Medium", settings.controlSizeLabel());
|
||||
assertEquals(1f, settings.controlScale(), 0f);
|
||||
|
||||
settings = settings.nextControlSize();
|
||||
assertEquals("Large", settings.controlSizeLabel());
|
||||
assertEquals(1.2f, settings.controlScale(), 0f);
|
||||
|
||||
settings = settings.nextControlSize().nextControlSize();
|
||||
assertEquals("Medium", settings.controlSizeLabel());
|
||||
assertEquals(1f, settings.controlScale(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void controlOpacityCyclesWithoutMakingControlsInvisible() {
|
||||
ControlSettings settings = ControlSettings.defaults();
|
||||
|
||||
assertEquals("Medium", settings.controlOpacityLabel());
|
||||
assertEquals(0.65f, settings.controlOpacity(), 0f);
|
||||
|
||||
settings = settings.nextControlOpacity();
|
||||
assertEquals("High", settings.controlOpacityLabel());
|
||||
assertEquals(0.9f, settings.controlOpacity(), 0f);
|
||||
|
||||
settings = settings.nextControlOpacity();
|
||||
assertEquals("Low", settings.controlOpacityLabel());
|
||||
assertEquals(0.45f, settings.controlOpacity(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vibrationCanBeDisabledAndEnabledAgain() {
|
||||
ControlSettings settings = ControlSettings.defaults();
|
||||
|
||||
assertEquals(true, settings.hapticsEnabled());
|
||||
settings = settings.toggleHaptics();
|
||||
assertEquals(false, settings.hapticsEnabled());
|
||||
assertEquals("Off", settings.hapticsLabel());
|
||||
assertEquals(true, settings.toggleHaptics().hapticsEnabled());
|
||||
}
|
||||
}
|
||||
@@ -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,43 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class GameStringsTest {
|
||||
@Test
|
||||
public void missingOrUnknownLanguageSafelyFallsBackToEnglish() {
|
||||
assertEquals(GameLanguage.EN, GameLanguage.fromCode(null));
|
||||
assertEquals(GameLanguage.EN, GameLanguage.fromCode("de"));
|
||||
|
||||
GameStrings strings = GameStrings.forLanguage(GameLanguage.fromCode("de"));
|
||||
assertEquals("Settings", strings.settingsButton());
|
||||
assertEquals("The Volunteer", strings.levelTitle(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void russianPackLocalizesTheCompletePlayerFacingContract() {
|
||||
GameStrings english = GameStrings.forLanguage(GameLanguage.EN);
|
||||
GameStrings russian = GameStrings.forLanguage(GameLanguage.RU);
|
||||
|
||||
assertEquals("Настройки", russian.settingsButton());
|
||||
assertEquals("Размер управления", russian.controlSizeSetting());
|
||||
assertEquals("Русский", russian.languageName());
|
||||
assertEquals("Рыцарь-доброволец", russian.levelTitle(1));
|
||||
assertEquals("Последнее шествие", russian.levelTitle(10));
|
||||
assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L));
|
||||
assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L));
|
||||
assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3));
|
||||
assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle());
|
||||
assertFalse(russian.tutorialSacrificeBody().trim().isEmpty());
|
||||
|
||||
for (int level = 1; level <= 10; level++) {
|
||||
assertFalse(russian.levelTitle(level).trim().isEmpty());
|
||||
assertFalse(russian.levelHint(level).trim().isEmpty());
|
||||
assertNotEquals(english.levelTitle(level), russian.levelTitle(level));
|
||||
assertNotEquals(english.levelHint(level), russian.levelHint(level));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.StringReader;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class LevelCatalogLoaderTest {
|
||||
@Test
|
||||
public void versionedIndexLoadsSequentialLevelsThroughResourceBoundary() {
|
||||
Map<String, String> resources = new HashMap<>();
|
||||
resources.put("levels/index.json", "{\"formatVersion\":1,\"physicsVersion\":1,"
|
||||
+ "\"levels\":[\"level-01.json\"]}");
|
||||
resources.put("levels/level-01.json", LevelJsonLoaderTest.validLevelJson(1));
|
||||
|
||||
List<LevelDefinition> levels = LevelCatalogLoader.load(path -> {
|
||||
String value = resources.get(path);
|
||||
return value == null ? null : new StringReader(value);
|
||||
}, "levels/index.json");
|
||||
|
||||
assertEquals(1, levels.size());
|
||||
assertEquals(1, levels.get(0).number);
|
||||
assertEquals("The Test", levels.get(0).title);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,19 @@ public final class LevelCatalogTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundledCatalogCarriesLocalizedCopyAndVersionedContent() {
|
||||
LevelDefinition first = LevelCatalog.get(0);
|
||||
LevelDefinition last = LevelCatalog.get(9);
|
||||
|
||||
assertEquals(LevelFormat.CURRENT_FORMAT_VERSION, first.formatVersion);
|
||||
assertEquals(LevelFormat.CURRENT_PHYSICS_VERSION, first.physicsVersion);
|
||||
assertEquals("Рыцарь-доброволец", first.titleRu);
|
||||
assertEquals("Последнее шествие", last.titleRu);
|
||||
assertFalse(first.hintRu.trim().isEmpty());
|
||||
assertFalse(last.hintRu.trim().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyLevelHasValidSpawnGoalAndGeometry() {
|
||||
for (LevelDefinition level : LevelCatalog.all()) {
|
||||
@@ -82,6 +95,94 @@ public final class LevelCatalogTest {
|
||||
assertFalse(finale.doors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void levelSixFreezerKillsOnTheFloorButNotOnTopOfItsFrozenBlock() {
|
||||
LevelDefinition level = LevelCatalog.get(5);
|
||||
LevelDefinition.Hazard freezer = null;
|
||||
for (LevelDefinition.Hazard hazard : level.hazards) {
|
||||
if (hazard.type == LevelDefinition.HazardType.FREEZER) freezer = hazard;
|
||||
}
|
||||
assertTrue("Level 6 must contain its freezer", freezer != null);
|
||||
|
||||
float frozenBlockTop = 880f - 104f;
|
||||
assertTrue("A floor-running volunteer must still hit the freezer",
|
||||
GameMath.intersects(688f, 780f, 62f, 100f,
|
||||
freezer.box.x, freezer.box.y, freezer.box.width, freezer.box.height));
|
||||
assertFalse("A volunteer standing on the frozen block must clear the freezer",
|
||||
GameMath.intersects(688f, frozenBlockTop - 100f, 62f, 100f,
|
||||
freezer.box.x, freezer.box.y, freezer.box.width, freezer.box.height));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masteryFreezerKillsOnTheFloorButNotOnTopOfItsFrozenBlock() {
|
||||
LevelDefinition level = LevelCatalog.get(9);
|
||||
LevelDefinition.Hazard freezer = null;
|
||||
for (LevelDefinition.Hazard hazard : level.hazards) {
|
||||
if (hazard.type == LevelDefinition.HazardType.FREEZER) freezer = hazard;
|
||||
}
|
||||
assertTrue("Level 10 must contain its freezer", freezer != null);
|
||||
|
||||
float frozenBlockTop = 880f - 104f;
|
||||
assertTrue("A floor-running volunteer must still hit the mastery freezer",
|
||||
GameMath.intersects(1_108f, 780f, 62f, 100f,
|
||||
freezer.box.x, freezer.box.y, freezer.box.width, freezer.box.height));
|
||||
assertFalse("A volunteer standing on the mastery frozen block must clear the freezer",
|
||||
GameMath.intersects(1_108f, frozenBlockTop - 100f, 62f, 100f,
|
||||
freezer.box.x, freezer.box.y, freezer.box.width, freezer.box.height));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masteryLiftHasLaunchOverlapAndStillSpansARealPit() {
|
||||
LevelDefinition level = LevelCatalog.get(9);
|
||||
LevelDefinition.Platform launchFloor = level.platforms.get(0);
|
||||
LevelDefinition.Platform landingFloor = level.platforms.get(1);
|
||||
LevelDefinition.Platform lift = null;
|
||||
LevelDefinition.Hazard pit = null;
|
||||
for (LevelDefinition.Platform platform : level.platforms) {
|
||||
if (platform.isMoving()) lift = platform;
|
||||
}
|
||||
for (LevelDefinition.Hazard hazard : level.hazards) {
|
||||
if (hazard.type == LevelDefinition.HazardType.PIT) pit = hazard;
|
||||
}
|
||||
assertTrue("Level 10 must contain its moving lift", lift != null);
|
||||
assertTrue("Level 10 must retain its final pit", pit != null);
|
||||
|
||||
float launchOverlap = Math.min(launchFloor.box.right(), lift.box.right())
|
||||
- Math.max(launchFloor.box.x, lift.box.x);
|
||||
assertTrue("The floor must overlap the lift by at least 140px for a delayed launch",
|
||||
launchOverlap >= 140f);
|
||||
assertEquals("The pit must begin exactly where the launch floor ends",
|
||||
launchFloor.box.right(), pit.box.x, 0f);
|
||||
assertEquals("The pit must continue to the landing floor",
|
||||
landingFloor.box.x, pit.box.right(), 0f);
|
||||
assertTrue("The final pit must remain at least 200px wide", pit.box.width >= 200f);
|
||||
assertTrue("The mastery lift cycle must leave a readable delayed-input boarding window",
|
||||
lift.periodSeconds >= 10f);
|
||||
assertTrue("The upper gallery must still require the vertical lift",
|
||||
lift.travelY <= -400f && level.goal.y < launchFloor.box.y - 300f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedPlatformHasLaunchOverlapAndStillSpansARealPit() {
|
||||
LevelDefinition level = LevelCatalog.get(7);
|
||||
LevelDefinition.Platform launchFloor = level.platforms.get(0);
|
||||
LevelDefinition.Platform landingFloor = level.platforms.get(1);
|
||||
LevelDefinition.Platform movingPlatform = level.platforms.get(3);
|
||||
LevelDefinition.Hazard pit = level.hazards.get(0);
|
||||
|
||||
float launchOverlap = Math.min(launchFloor.box.right(), movingPlatform.box.right())
|
||||
- Math.max(launchFloor.box.x, movingPlatform.box.x);
|
||||
assertTrue("The timed platform must overlap the launch floor by at least 50px",
|
||||
launchOverlap >= 50f);
|
||||
assertTrue("The timed platform needs a 320px left-side catch window",
|
||||
movingPlatform.box.width >= 320f);
|
||||
assertEquals("Widening must preserve the original right edge for recovery safety",
|
||||
1_230f, movingPlatform.box.right(), 0f);
|
||||
assertEquals(launchFloor.box.right(), pit.box.x, 0f);
|
||||
assertEquals(landingFloor.box.x, pit.box.right(), 0f);
|
||||
assertTrue("Borrowed Time must retain a real platforming pit", pit.box.width >= 450f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyDoorAndControlledPlatformHasADeclaredSignalSource() {
|
||||
for (LevelDefinition level : LevelCatalog.all()) {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.StringReader;
|
||||
|
||||
public final class LevelJsonLoaderTest {
|
||||
@Test
|
||||
public void versionedJsonLoadsLocalizedLevelThroughPublicContract() {
|
||||
String json = validLevelJson(1);
|
||||
|
||||
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "test-level.json");
|
||||
|
||||
assertEquals(1, level.formatVersion);
|
||||
assertEquals(1, level.physicsVersion);
|
||||
assertEquals(1, level.number);
|
||||
assertEquals("The Test", level.title);
|
||||
assertEquals("Reach the goal.", level.hint);
|
||||
assertEquals("Испытание", level.titleRu);
|
||||
assertEquals("Доберитесь до цели.", level.hintRu);
|
||||
assertEquals(1920f, level.worldWidth, 0f);
|
||||
assertEquals(1080f, level.worldHeight, 0f);
|
||||
assertEquals(1, level.platforms.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownFormatVersionIsRejectedWithSourcePath() {
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(validLevelJson(2)), "future-level.json");
|
||||
fail("Unknown format versions must fail closed");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("future-level.json.formatVersion"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("expected 1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doorWithUnknownSignalIsRejectedBeforeEnteringCatalog() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[],"
|
||||
+ "\"switches\":[{\"type\":\"PRESSURE\","
|
||||
+ "\"box\":{\"x\":540,\"y\":860,\"width\":150,\"height\":20},"
|
||||
+ "\"signalId\":\"gate\",\"durationSeconds\":0,\"requiredMass\":1}],"
|
||||
+ "\"doors\":[{\"box\":{\"x\":1110,\"y\":570,\"width\":78,\"height\":310},"
|
||||
+ "\"requiredSignals\":[\"missing\"]}]");
|
||||
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(json), "broken-signal.json");
|
||||
fail("Unknown signal references must be rejected");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains(
|
||||
"broken-signal.json.doors[0].requiredSignals[0]"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("missing signal 'missing'"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonPositiveGeometryIsRejectedWithObjectPath() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"width\":1920,\"height\":200",
|
||||
"\"width\":0,\"height\":200");
|
||||
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(json), "flat-platform.json");
|
||||
fail("Non-positive platform geometry must be rejected");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains(
|
||||
"flat-platform.json.platforms[0].box.width"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("must be positive"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentPuzzleObjectsLoadWithoutJavaGeometryFallback() {
|
||||
String json = validLevelJson(1)
|
||||
.replace(
|
||||
"{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}",
|
||||
"{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200},"
|
||||
+ "\"travelX\":0,\"travelY\":-300,\"periodSeconds\":5.2,"
|
||||
+ "\"controlId\":\"lift\"}")
|
||||
.replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"SAW\","
|
||||
+ "\"box\":{\"x\":565,\"y\":760,\"width\":92,\"height\":92},"
|
||||
+ "\"impulseX\":500,\"impulseY\":-700}],"
|
||||
+ "\"switches\":[{\"type\":\"PRESSURE\","
|
||||
+ "\"box\":{\"x\":540,\"y\":860,\"width\":150,\"height\":20},"
|
||||
+ "\"signalId\":\"lift\",\"durationSeconds\":0,\"requiredMass\":1}],"
|
||||
+ "\"doors\":[{\"box\":{\"x\":1110,\"y\":570,\"width\":78,\"height\":310},"
|
||||
+ "\"requiredSignals\":[\"lift\"]}]");
|
||||
|
||||
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "mechanics.json");
|
||||
|
||||
assertEquals(1, level.platforms.size());
|
||||
assertEquals(-300f, level.platforms.get(0).travelY, 0f);
|
||||
assertEquals(5.2f, level.platforms.get(0).periodSeconds, 0f);
|
||||
assertEquals("lift", level.platforms.get(0).controlId);
|
||||
assertEquals(LevelDefinition.HazardType.SAW, level.hazards.get(0).type);
|
||||
assertEquals(500f, level.hazards.get(0).impulseX, 0f);
|
||||
assertEquals(1, level.switches.size());
|
||||
assertEquals(1, level.doors.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedSwitchRequiresPositiveDuration() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[],"
|
||||
+ "\"switches\":[{\"type\":\"TIMED\","
|
||||
+ "\"box\":{\"x\":560,\"y\":810,\"width\":90,\"height\":70},"
|
||||
+ "\"signalId\":\"clock\",\"durationSeconds\":0,\"requiredMass\":1}],"
|
||||
+ "\"doors\":[]");
|
||||
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(json), "zero-timer.json");
|
||||
fail("A timed switch with no active window must be rejected");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains(
|
||||
"zero-timer.json.switches[0].durationSeconds"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("must be positive"));
|
||||
}
|
||||
}
|
||||
|
||||
static String validLevelJson(int formatVersion) {
|
||||
return "{"
|
||||
+ "\"formatVersion\":" + formatVersion + ","
|
||||
+ "\"physicsVersion\":1,"
|
||||
+ "\"number\":1,"
|
||||
+ "\"copy\":{"
|
||||
+ "\"en\":{\"title\":\"The Test\",\"hint\":\"Reach the goal.\"},"
|
||||
+ "\"ru\":{\"title\":\"Испытание\",\"hint\":\"Доберитесь до цели.\"}},"
|
||||
+ "\"world\":{\"width\":1920,\"height\":1080},"
|
||||
+ "\"spawn\":{\"x\":130,\"y\":760},"
|
||||
+ "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110},"
|
||||
+ "\"targets\":{\"deaths\":0,\"seconds\":30},"
|
||||
+ "\"platforms\":[{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}],"
|
||||
+ "\"hazards\":[],\"switches\":[],\"doors\":[]"
|
||||
+ "}";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.projectsacrifice.game;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -23,6 +24,72 @@ public final class LevelPlaytestBotTest {
|
||||
assertTrue(result.diagnostic(), result.completed());
|
||||
assertEquals(7, driver.startedLevel);
|
||||
assertEquals("Reach the goal", result.lastStep());
|
||||
assertTrue("Completed steps must release held controls before the next action",
|
||||
driver.inputReleases >= 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resultExposesDeterministicVersionedCommandTrace() {
|
||||
ScriptedDriver driver = new ScriptedDriver();
|
||||
LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario
|
||||
.builder(7, "Trace scenario")
|
||||
.step("Create a corpse", 1_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() >= 1)
|
||||
.step("Reach the goal", 1_000L, LevelPlaytestBot.Input.left(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L);
|
||||
String trace = result.traceJson();
|
||||
|
||||
assertEquals(trace, result.traceJson());
|
||||
assertTrue(trace, trace.contains("\"traceVersion\":1"));
|
||||
assertTrue(trace, trace.contains("\"level\":7"));
|
||||
assertTrue(trace, trace.contains("\"levelFormatVersion\":1"));
|
||||
assertTrue(trace, trace.contains("\"physicsVersion\":1"));
|
||||
assertTrue(trace, trace.contains("\"scenario\":\"Trace scenario\""));
|
||||
assertTrue(trace, trace.contains("\"kind\":\"INPUT\""));
|
||||
assertTrue(trace, trace.contains("\"simulationMs\":"));
|
||||
assertFalse(trace, trace.contains("wallElapsed"));
|
||||
assertFalse(trace, trace.contains("device"));
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("traceId=" + result.traceId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tracingDriverCoversBespokeProductionInputScenarios() {
|
||||
BotTraceDriver driver = new BotTraceDriver(new ScriptedDriver(), "Bespoke route");
|
||||
driver.startLevel(8);
|
||||
driver.step("Board platform");
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(16L);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
|
||||
BotCommandTrace trace = driver.finish("COMPLETED", "Board platform", snapshot);
|
||||
|
||||
assertTrue(trace.json(), trace.json().contains("\"scenario\":\"Bespoke route\""));
|
||||
assertTrue(trace.json(), trace.json().contains("\"step\":\"Board platform\""));
|
||||
assertTrue(trace.json(), trace.json().contains(
|
||||
"\"input\":{\"left\":false,\"right\":true,\"jump\":true}"));
|
||||
assertEquals(64, trace.id().length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameDriverCanRestartTheCompleteScenarioThreeTimes() {
|
||||
ScriptedDriver driver = new ScriptedDriver();
|
||||
LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario
|
||||
.builder(7, "Repeated scenario")
|
||||
.step("Create a corpse", 1_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() >= 1)
|
||||
.step("Reach the goal", 1_000L, LevelPlaytestBot.Input.left(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
|
||||
for (int pass = 1; pass <= 3; pass++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L);
|
||||
assertTrue("Pass " + pass + ": " + result.diagnostic(), result.completed());
|
||||
assertEquals("Pass " + pass, "Reach the goal", result.lastStep());
|
||||
assertEquals("Pass " + pass, 1, result.deaths());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -47,6 +114,41 @@ public final class LevelPlaytestBotTest {
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("allDoorsOpen=false"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void slowRenderingUsesSimulationElapsedBudgetsInsteadOfHostTime() {
|
||||
SlowRenderingDriver driver = new SlowRenderingDriver(true);
|
||||
LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario
|
||||
.builder(1, "Slow rendering")
|
||||
.step("Reach simulated checkpoint", 200L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 500L);
|
||||
|
||||
assertTrue(result.diagnostic(), result.completed());
|
||||
assertTrue("The host clock must be allowed to outrun simulated gameplay",
|
||||
driver.wallMillis > 500L);
|
||||
assertEquals(96L, driver.simulationMillis);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frozenSimulationStopsAtWallClockWatchdogWithDiagnostic() {
|
||||
SlowRenderingDriver driver = new SlowRenderingDriver(false);
|
||||
LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario
|
||||
.builder(1, "Frozen rendering")
|
||||
.step("Wait for simulation", 1_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L);
|
||||
|
||||
assertFalse(result.completed());
|
||||
assertEquals("Wait for simulation", result.lastStep());
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("wall-clock watchdog"));
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("simulationElapsed=0"));
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("wallElapsed=120000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void levelFiveUsesTheGenericNamedScenario() {
|
||||
LevelFiveDriver driver = new LevelFiveDriver();
|
||||
@@ -61,6 +163,62 @@ public final class LevelPlaytestBotTest {
|
||||
assertTrue(result.doorOpened());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void levelFiveDelayedDecisionStillClearsTheSawWithOneVolunteer() {
|
||||
LevelFiveDriver driver = new LevelFiveDriver(true);
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().proveLevelFive(driver, 25_000L);
|
||||
|
||||
assertTrue(result.diagnostic(), result.completed());
|
||||
assertEquals(1, result.deaths());
|
||||
assertEquals(1, result.corpseCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void automationSnapshotDefaultsToNoMovingPlatform() {
|
||||
GameView.AutomationSnapshot snapshot = new GameView.AutomationSnapshot(
|
||||
false, true, false, false, false,
|
||||
1, 0L, 130f, 760f, 0, 0);
|
||||
|
||||
assertTrue(Float.isNaN(snapshot.firstMovingPlatformY()));
|
||||
assertFalse(snapshot.playerOnMovingPlatform());
|
||||
assertFalse(snapshot.firstMovingPlatformMovingDown());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void groundedApproachWaitsForAliveGroundedPlayerAtCheckpoint() {
|
||||
LevelPlaytestBot.SnapshotCondition checkpoint =
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(280f);
|
||||
|
||||
assertFalse(checkpoint.reached(snapshot(true, true, 279f, 1)));
|
||||
assertFalse(checkpoint.reached(snapshot(false, true, 280f, 1)));
|
||||
assertFalse(checkpoint.reached(snapshot(true, false, 280f, 1)));
|
||||
assertTrue(checkpoint.reached(snapshot(true, true, 280f, 1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expectedDeathGuardFailsBeforeAnAccidentalRetry() {
|
||||
LevelPlaytestBot.SnapshotCondition guarded = LevelPlaytestBot.whileDeathsAtMost(
|
||||
1, snapshot -> snapshot.playerX() >= 280f);
|
||||
|
||||
assertTrue(guarded.reached(snapshot(true, false, 320f, 1)));
|
||||
try {
|
||||
guarded.reached(snapshot(false, false, 470f, 2));
|
||||
fail("An accidental second death must fail the route immediately");
|
||||
} catch (AssertionError expected) {
|
||||
assertTrue(expected.getMessage(), expected.getMessage().contains("expected at most 1"));
|
||||
assertTrue(expected.getMessage(), expected.getMessage().contains("observed 2"));
|
||||
}
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot snapshot(
|
||||
boolean alive, boolean onGround, float x, int deaths) {
|
||||
return new GameView.AutomationSnapshot(
|
||||
false, alive, false, false, false,
|
||||
10, 0L, x, 760f, deaths, deaths,
|
||||
Float.NaN, Float.NaN, false, false, Float.NaN, onGround);
|
||||
}
|
||||
|
||||
private static final class ScriptedDriver implements LevelPlaytestBot.Driver {
|
||||
private int startedLevel;
|
||||
private long nowMillis;
|
||||
@@ -69,6 +227,7 @@ public final class LevelPlaytestBotTest {
|
||||
private int deaths;
|
||||
private boolean completed;
|
||||
private final boolean progresses;
|
||||
private int inputReleases;
|
||||
|
||||
ScriptedDriver() {
|
||||
this(true);
|
||||
@@ -81,10 +240,16 @@ public final class LevelPlaytestBotTest {
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
startedLevel = oneBasedLevelNumber;
|
||||
nowMillis = 0L;
|
||||
moveLeft = false;
|
||||
moveRight = false;
|
||||
deaths = 0;
|
||||
completed = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
if (!moveLeft && !moveRight && !jump) inputReleases++;
|
||||
this.moveLeft = moveLeft;
|
||||
this.moveRight = moveRight;
|
||||
}
|
||||
@@ -122,7 +287,52 @@ public final class LevelPlaytestBotTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SlowRenderingDriver implements LevelPlaytestBot.Driver {
|
||||
private final boolean advancesSimulation;
|
||||
private long wallMillis;
|
||||
private long simulationMillis;
|
||||
private boolean movingRight;
|
||||
private boolean completed;
|
||||
|
||||
SlowRenderingDriver(boolean advancesSimulation) {
|
||||
this.advancesSimulation = advancesSimulation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
wallMillis = 0L;
|
||||
simulationMillis = 0L;
|
||||
movingRight = false;
|
||||
completed = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
movingRight = moveRight;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
return new GameView.AutomationSnapshot(
|
||||
completed, true, false, false, movingRight,
|
||||
1, simulationMillis, 130f, 760f, 0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
wallMillis += advancesSimulation ? 1_000L : 60_000L;
|
||||
if (advancesSimulation) simulationMillis += millis;
|
||||
if (movingRight && simulationMillis >= 96L) completed = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long currentTimeMillis() {
|
||||
return wallMillis;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LevelFiveDriver implements LevelPlaytestBot.Driver {
|
||||
private final boolean overshootApproach;
|
||||
private int startedLevel;
|
||||
private long nowMillis;
|
||||
private boolean moveRight;
|
||||
@@ -133,6 +343,16 @@ public final class LevelPlaytestBotTest {
|
||||
private int deaths;
|
||||
private int corpses;
|
||||
private float x = 130f;
|
||||
private float y = 760f;
|
||||
private boolean sawTakeoffObserved;
|
||||
|
||||
LevelFiveDriver() {
|
||||
this(false);
|
||||
}
|
||||
|
||||
LevelFiveDriver(boolean overshootApproach) {
|
||||
this.overshootApproach = overshootApproach;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
@@ -149,7 +369,7 @@ public final class LevelPlaytestBotTest {
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
return new GameView.AutomationSnapshot(
|
||||
completed, alive, doorOpen, false, moveRight || jump,
|
||||
startedLevel, nowMillis, x, 760f, deaths, corpses);
|
||||
startedLevel, nowMillis, x, y, deaths, corpses);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -158,6 +378,7 @@ public final class LevelPlaytestBotTest {
|
||||
if (!alive) {
|
||||
alive = true;
|
||||
x = 130f;
|
||||
y = 760f;
|
||||
} else if (deaths == 0 && moveRight && !jump) {
|
||||
deaths = 1;
|
||||
corpses = 1;
|
||||
@@ -165,11 +386,24 @@ public final class LevelPlaytestBotTest {
|
||||
doorOpen = true;
|
||||
x = 570f;
|
||||
} else if (moveRight && jump) {
|
||||
if (x < 750f) {
|
||||
sawTakeoffObserved = true;
|
||||
y = 690f;
|
||||
}
|
||||
x += 60f;
|
||||
} else if (moveRight && x < 390f) {
|
||||
x = 400f;
|
||||
x = overshootApproach ? 440f : 400f;
|
||||
} else if (moveRight && x < 750f) {
|
||||
x = 760f;
|
||||
if (overshootApproach && !sawTakeoffObserved) {
|
||||
deaths++;
|
||||
corpses++;
|
||||
alive = false;
|
||||
x = 510f;
|
||||
y = 780f;
|
||||
} else {
|
||||
x = 760f;
|
||||
y = 760f;
|
||||
}
|
||||
} else if (moveRight && x < 1_390f) {
|
||||
x = 1_500f;
|
||||
} else if (moveRight) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class MovingPlatformLaunchPolicyTest {
|
||||
@Test
|
||||
public void boardingWaitsForThePoweredOutboundPhaseNearTheLeftFloor() {
|
||||
assertFalse(MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot(1_000f, false, true, true, false), 1_050f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot(1_000f, false, true, false, true), 1_050f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot(1_100f, false, true, true, true), 1_050f));
|
||||
assertTrue(MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot(1_000f, false, true, true, true), 1_050f));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void launchRequiresStablePoweredSupportInTheLaunchZone() {
|
||||
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(910f, true, true, true, true), 1_150f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, false, true, true, true), 1_150f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, false, true, true), 1_150f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, false, true), 1_150f));
|
||||
assertTrue(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, true, true), 1_150f));
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot snapshot(
|
||||
float platformX, boolean supported, boolean grounded, boolean powered,
|
||||
boolean movingRight
|
||||
) {
|
||||
return new GameView.AutomationSnapshot(
|
||||
false, true, true, false, false, 8,
|
||||
4_000L, 1_100f, 670f, 0, 0,
|
||||
platformX, 770f, supported, powered, movingRight, Float.NaN, grounded
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class OnboardingFlowTest {
|
||||
@Test
|
||||
public void firstRunExplainsControlsAndCorpsesThenStaysDismissed() {
|
||||
OnboardingFlow flow = OnboardingFlow.firstRun(false);
|
||||
|
||||
assertTrue(flow.isVisible());
|
||||
assertEquals(OnboardingFlow.Step.MOVEMENT, flow.step());
|
||||
|
||||
flow.next();
|
||||
assertEquals(OnboardingFlow.Step.MULTITOUCH_JUMP, flow.step());
|
||||
|
||||
flow.next();
|
||||
assertEquals(OnboardingFlow.Step.CORPSES_ARE_TOOLS, flow.step());
|
||||
|
||||
flow.next();
|
||||
assertFalse(flow.isVisible());
|
||||
assertTrue(flow.isComplete());
|
||||
assertFalse(OnboardingFlow.firstRun(flow.isComplete()).isVisible());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstRunCanBeSkippedImmediately() {
|
||||
OnboardingFlow flow = OnboardingFlow.firstRun(false);
|
||||
|
||||
flow.skip();
|
||||
|
||||
assertFalse(flow.isVisible());
|
||||
assertTrue(flow.isComplete());
|
||||
}
|
||||
}
|
||||
@@ -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,21 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class PlaytestSimulationBudgetTest {
|
||||
@Test
|
||||
public void slowRenderingUsesSimulationBudgetWithASeparateWallWatchdog() {
|
||||
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(0L, 0L, 500L);
|
||||
|
||||
assertFalse("Host time may outrun active simulation", budget.exhausted(96L, 1_000L));
|
||||
assertTrue("Simulation budget remains authoritative", budget.exhausted(500L, 5_000L));
|
||||
|
||||
PlaytestSimulationBudget frozen = PlaytestSimulationBudget.start(0L, 0L, 5_000L);
|
||||
assertFalse(frozen.exhausted(0L, 119_999L));
|
||||
assertTrue("Frozen rendering stops at the same watchdog as the generic bot",
|
||||
frozen.exhausted(0L, 120_000L));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -13,6 +13,7 @@ master tracks origin/master
|
||||
|
||||
- [`DEVELOPMENT_PLAN.md`](DEVELOPMENT_PLAN.md) — утверждённый GDD + TDD + production roadmap;
|
||||
- [`CURRENT_ROADMAP.md`](CURRENT_ROADMAP.md) — ближайшая последовательность работ для существующего vertical slice.
|
||||
- [`STAGE_REGISTRY.md`](STAGE_REGISTRY.md) — обязательная stage-классификация всех Beads от Prototype до Release Candidate.
|
||||
|
||||
## Структура backlog
|
||||
|
||||
@@ -22,9 +23,14 @@ master tracks origin/master
|
||||
- предметные задачи с acceptance criteria, приоритетами и зависимостями;
|
||||
- уже подтверждённые кодом и QA элементы закрыты с причиной.
|
||||
|
||||
На момент инициализации создано 68 beads: 13 закрыты, 4 находятся в работе и 51 открыт. Эти числа нельзя трактовать как точный процент готовности, потому что в базе смешаны эпики, features и задачи разного размера.
|
||||
На 18 августа 2026 года в snapshot находятся 78 beads. Эти числа нельзя
|
||||
трактовать как точный процент готовности, потому что в базе смешаны эпики,
|
||||
features и задачи разного размера.
|
||||
|
||||
Текущий gate — `goon-game-milestone-vslice`. Первая приоритетная незаблокированная задача — `goon-game-bot-framework`.
|
||||
Этапы 0–5 не содержат незакрытых primary-задач. Текущий primary gate — Stage 6:
|
||||
production content 55–70 уровней, четыре мира, расширение mechanics/challenges и
|
||||
production progression. Ограничения внешнего Stage 5 evidence зафиксированы в
|
||||
`STAGE5_HUMAN_PLAYTEST_RESULT.md`.
|
||||
|
||||
## Ежедневные команды
|
||||
|
||||
@@ -60,6 +66,7 @@ bd close goon-game-bot-framework --reason "Acceptance criteria выполнен
|
||||
```sh
|
||||
bd dep cycles
|
||||
bd export --output .beads/issues.jsonl
|
||||
./scripts/check-stage-assignments.sh
|
||||
```
|
||||
|
||||
Завершение сессии:
|
||||
@@ -81,3 +88,6 @@ git status --short --branch
|
||||
5. Не редактировать `.beads/issues.jsonl` вручную: source of truth — embedded Dolt в `.beads/embeddeddolt`.
|
||||
6. Git remote обязателен для исходников и JSONL snapshot; после каждого изменения backlog экспортировать JSONL, коммитить и делать push.
|
||||
7. `bd dolt push` публикует отдельную внутреннюю историю и выполняется только после явного разрешения владельца.
|
||||
8. Каждая Bead обязана иметь минимум один `stage-N`, ровно один
|
||||
`primary-stage-N` и совпадающую metadata `primary_stage`; новые задачи без
|
||||
этой классификации не должны попадать в commit.
|
||||
|
||||
+119
-15
@@ -6,17 +6,41 @@
|
||||
|
||||
## 1. Текущее состояние
|
||||
|
||||
Проект находится на стадии проверенного vertical slice:
|
||||
Этапы 0–4 завершены по primary-stage ownership:
|
||||
|
||||
- реализованы 10 последовательных уровней;
|
||||
- работают движение, прыжок и мобильное multitouch-управление;
|
||||
- смерть не сбрасывает состояние уровня, тела остаются физическими объектами;
|
||||
- реализованы шипы, пила, нажимные и временные кнопки, двери, движущиеся платформы, заморозка и электричество;
|
||||
- сохраняются прогресс, лучшее время и минимальное число жертв;
|
||||
- уровень 5 защищён Android acceptance-тестом: бот проходит реальную игровую сцену примерно за 8,2 секунды с одной смертью и одним оставшимся телом;
|
||||
- intended route всех уровней 1–10 защищён Android acceptance-ботами: каждый маршрут выполняется по три раза через production-ввод без прямого перемещения персонажа;
|
||||
- blocker-softlock уровня 8 после истечения таймера устранён recovery relay и покрыт отдельным трёхкратным сценарием;
|
||||
- исправлено перекрытие `FREEZER` уровня 6, которое повторно убивало игрока на frozen stepping stone;
|
||||
- тело, доставленное пилой на `PRESSURE` уровня 10, фиксируется после оседания и больше не соскальзывает с плиты;
|
||||
- restart из промежуточного состояния уровня 10 очищает тела, сигналы, таймер и удерживаемый ввод;
|
||||
- Settings screen сохраняет размер и opacity touch-controls и режим вибрации; три form-factor контракта и защита от повторного held-jump покрыты device-тестами;
|
||||
- добавлены сохраняемые Standard/Large text и high-contrast режимы, видимый статус сигналов/дверей/respawn и локализованные content descriptions;
|
||||
- короткое трёхшаговое обучение не блокирует gameplay и после Skip/Done не показывается повторно;
|
||||
- экран результата разделяет текущий и лучший результаты, показывает targets, следующую цель и рабочие Level select/Replay/Next level переходы;
|
||||
- весь Canvas UI, десять названий и подсказок доступны на русском и английском; язык хранится отдельно и не меняет progress/control settings;
|
||||
- Stage 3 focused matrix: 14/14 методов без retries; UI contracts GREEN на 20:9, 16:9 и логическом 16:10 tablet viewport;
|
||||
- final Stage 3 Android 16 matrix: `OK (30 tests)` без filters/retries за `1,409.366 s`; все intended routes 1–10, recovery/stress, lifecycle, restart и UI/persistence contracts GREEN;
|
||||
- все десять уровней и RU/EN copy перенесены из Java-констант в versioned JSON с `formatVersion=1` и `physicsVersion=1`;
|
||||
- общий pure-Java loader/validator используется Android runtime и desktop tooling; повреждённая геометрия, версия или ссылка на signal останавливает build до APK packaging;
|
||||
- deterministic desktop exporter создаёт десять SVG и единый coordinate TSV с byte-identical результатом для неизменного каталога;
|
||||
- acceptance bots сохраняют versioned command/checkpoint trace и SHA-256 trace ID без wall-clock или прямых mutation-команд;
|
||||
- final Stage 4 Android 16 matrix: `OK (30 tests)` без filters/retries за `1,096.448 s`; вся Stage 3 gameplay/UI матрица GREEN после JSON-миграции;
|
||||
- final integrated Android 16 matrix: 16/16 методов, 36 игровых выполнений, lifecycle и restart; wall time `9:32.496`;
|
||||
- финальный debug APK проверен на Android 16: установка, холодный запуск, ZIP-структура и v1/v2-подписи корректны.
|
||||
|
||||
Главный текущий риск: автоматическая проходимость доказана только для уровня 5. Остальные уровни могут содержать тупики, нестабильную физику или слишком точные действия.
|
||||
Автоматическая проходимость, устойчивость обязательных объектов и чистый restart доказаны. Внешняя понятность уровней не может быть сертифицирована самим automation-runner: две ручные сессии на уровень остаются отдельным внешним playtest-gate и не считаются выполненными этим отчётом.
|
||||
|
||||
Performance-оговорка: исторический критерий этапа 1 «полный прогон до 5 минут» не выполнен на текущем throttled AVD. После расширения набора до 16 методов exact final matrix занимает `9:32.496`. Сокращать трёхкратные прохождения или скрывать нестабильность retries запрещено; оптимизация runner остаётся отдельной QA-задачей и не меняет доказанную проходимость.
|
||||
|
||||
`goon-game-regression-10` сохраняет вторичную связь со Stage 1, а
|
||||
`goon-game-level-balance` — со Stage 2. Их незавершённые performance и external
|
||||
comprehension gates имеют primary Stage 5, поэтому завершённые инженерные этапы
|
||||
не содержат скрытых открытых primary-задач.
|
||||
|
||||
## 2. Принципы разработки
|
||||
|
||||
@@ -30,6 +54,13 @@
|
||||
|
||||
## 3. Этапы
|
||||
|
||||
### Этап 0. Prototype foundation — завершён
|
||||
|
||||
В этот исторический этап входят базовый Android APK, character/death/corpse,
|
||||
Canvas physics, camera, puzzle objects, hazards, HUD/multitouch, save, unit tests
|
||||
и greybox-набор из десяти уровней. Он сохраняется в registry, чтобы завершённые
|
||||
baseline-задачи также имели явного владельца по stage.
|
||||
|
||||
### Этап 1. Боты проходимости для уровней 1–10 — P0, 4–6 дней
|
||||
|
||||
Задачи:
|
||||
@@ -101,7 +132,7 @@
|
||||
- некорректный сигнал, дверь или объект отклоняется валидатором при сборке;
|
||||
- все существующие уровни после миграции проходят тех же ботов.
|
||||
|
||||
### Этап 5. Художественная и звуковая альфа — P1, 8–15 дней
|
||||
### Этап 5. Завершение Vertical Slice — P0/P1, 10–18 дней
|
||||
|
||||
Задачи:
|
||||
|
||||
@@ -110,15 +141,44 @@
|
||||
- добавить анимации движения, смерти, respawn и реакции механизмов;
|
||||
- добавить звуки управления, механизмов, результата и фоновую музыку;
|
||||
- предусмотреть независимые настройки музыки, эффектов и вибрации;
|
||||
- оптимизировать ресурсы для Android.
|
||||
- завершить lifecycle/process-death и versioned save migration;
|
||||
- доказать performance budget и три полных playability regression прогона;
|
||||
- провести внешний playtest уровней 1–10 без объяснений разработчика;
|
||||
- закрыть оставшийся comprehension/balance gate.
|
||||
|
||||
Критерии завершения:
|
||||
|
||||
- все игровые состояния визуально различимы без чтения внутренней логики;
|
||||
- звук не является единственным источником обязательной информации;
|
||||
- проект сохраняет стабильную частоту кадров на целевых устройствах.
|
||||
- progress восстанавливается после Activity/process recreation и обновления приложения;
|
||||
- `goon-game-milestone-vslice` принят вместе с performance и human-playtest gates.
|
||||
|
||||
### Этап 6. Техническая стабилизация — P0 перед внешней альфой, 4–7 дней
|
||||
Статус на 19 августа 2026: Stage 5 принят. Инженерный candidate
|
||||
`0.8.0-stage5-vslice` прошёл 72 JVM-теста и единый Android 16 run `35/35`
|
||||
(`493.521 s`). Владелец продукта подтвердил завершение внешнего тестирования и
|
||||
переход дальше; границы доступного human evidence зафиксированы в
|
||||
`STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 6 разблокирован.
|
||||
|
||||
### Этап 6. Production content — P1, несколько итераций
|
||||
|
||||
Задачи:
|
||||
|
||||
- спроектировать и произвести четыре мира и 55–70 уровней;
|
||||
- расширить hazards, corpse states и weighted physical objects;
|
||||
- завершить character state machine, CorpseManager и event-driven PuzzleSystem;
|
||||
- добавить challenges, collectibles и production progression;
|
||||
- расширить модульные варианты персонажей без нарушения анимационных контрактов;
|
||||
- проводить каждый уровень через JSON validator, solution graph, bot и level DoD.
|
||||
|
||||
Критерии завершения:
|
||||
|
||||
- все production-уровни проходят content pipeline и имеют versioned bot trace;
|
||||
- новые mechanics сначала обучаются отдельно и только затем комбинируются;
|
||||
- content не содержит blocker-softlocks и обязательных невосстановимых объектов;
|
||||
- принят `goon-game-milestone-production`.
|
||||
|
||||
### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
|
||||
|
||||
Задачи:
|
||||
|
||||
@@ -129,6 +189,7 @@
|
||||
- проверить обновление приложения поверх предыдущей версии и сохранность прогресса;
|
||||
- подготовить release build, отдельный application ID для тестового канала и закрыто хранимый keystore;
|
||||
- сформировать APK для прямой установки и AAB для Google Play Internal Testing.
|
||||
- зафиксировать gameplay analytics contract и crash/ANR reporting без персональных данных.
|
||||
|
||||
Критерии завершения:
|
||||
|
||||
@@ -137,7 +198,7 @@
|
||||
- release APK/AAB подписан, устанавливается и запускается на чистом устройстве;
|
||||
- debug-инструменты не попадают в публичную конфигурацию без необходимости.
|
||||
|
||||
### Этап 7. Закрытая альфа — 1–2 итерации по 5–7 дней
|
||||
### Этап 8. Закрытая альфа — 1–2 итерации по 5–7 дней
|
||||
|
||||
Задачи:
|
||||
|
||||
@@ -154,6 +215,36 @@
|
||||
- отсутствуют открытые блокирующие дефекты;
|
||||
- все значимые дефекты имеют решение или явно принятое продуктовое решение.
|
||||
|
||||
### Этап 9. Beta — 1–3 стабилизационные итерации
|
||||
|
||||
Задачи:
|
||||
|
||||
- объявить feature/content freeze;
|
||||
- выполнить широкую device, upgrade, save-migration и localization QA matrix;
|
||||
- исправлять blocker/critical/major defects без добавления новых mechanics;
|
||||
- повторять production bots, analytics и ручные smoke-сценарии на каждом кандидате.
|
||||
|
||||
Критерии завершения:
|
||||
|
||||
- beta QA matrix зелёная;
|
||||
- нет blocker и critical progression defects;
|
||||
- content, format/physics versions и save migrations заморожены для RC.
|
||||
|
||||
### Этап 10. Release Candidate
|
||||
|
||||
Задачи:
|
||||
|
||||
- собрать финальные signed APK/AAB из воспроизводимого commit;
|
||||
- проверить store metadata, privacy, analytics и crash reporting;
|
||||
- выполнить финальный полный regression, чистую установку и upgrade path;
|
||||
- зафиксировать известные ограничения и release decision.
|
||||
|
||||
Критерии завершения:
|
||||
|
||||
- `goon-game-milestone-rc` принят;
|
||||
- нет blocker/critical defects, все уровни проходимы;
|
||||
- release signatures, artifacts, remote commit и сохранность прогресса подтверждены.
|
||||
|
||||
## 4. Обязательный набор проверок каждого APK
|
||||
|
||||
Перед передачей тестировщикам должны выполняться:
|
||||
@@ -168,14 +259,27 @@
|
||||
8. холодный запуск и проверка crash-буфера;
|
||||
9. ручной smoke-тест уровня 1 и одного сложного комбинированного уровня.
|
||||
|
||||
## 5. Ближайшая итерация
|
||||
## 5. Завершение этапов 2–4 и следующая итерация
|
||||
|
||||
Следующая итерация ограничивается этапом 1:
|
||||
Инженерный scope этапа 2 закрыт: уровни 1–10 проходят intended route по три раза, уровень 8 имеет restart-free recovery, уровень 10 использует все три тела и сохраняет тело на lift-плите, а restart возвращает чистое состояние. Передаваемые результаты — обновлённый проект, проверенный APK и [`STAGE2_QA_REPORT.md`](STAGE2_QA_REPORT.md).
|
||||
|
||||
- обобщить существующего бота уровня 5;
|
||||
- добавить ботов уровней 1–4;
|
||||
- затем добавить ботов уровней 6–10;
|
||||
- зафиксировать единый отчёт `10/10 passed`;
|
||||
- не добавлять уровень 11 и новые механики до выполнения этого критерия.
|
||||
Этап 3 завершён: к сохраняемым пресетам управления добавлены accessibility,
|
||||
неблокирующее обучение, полный result flow и RU/EN локализация. Совместный
|
||||
device-gate проверяет сохранность progress/settings, а UI contracts выполнены на
|
||||
20:9, 16:9 и tablet-sized экране. Подробный итог находится в
|
||||
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md).
|
||||
|
||||
Результатом итерации должны стать обновлённый исходный проект, проверенный APK и отчёт с длительностью и результатом каждого сценария.
|
||||
Этап 4 завершён: каталог загружается из десяти JSON-ресурсов, общий validator
|
||||
запускается до Android packaging, а desktop-команда создаёт deterministic SVG/TSV
|
||||
preview. Боты публикуют versioned trace с идентификатором для диагностики
|
||||
регрессий; неизменность gameplay подтверждена полным Android regression.
|
||||
Контракт и evidence находятся в [`STAGE4_CONTENT_FORMAT.md`](STAGE4_CONTENT_FORMAT.md)
|
||||
и [`STAGE4_QA_REPORT.md`](STAGE4_QA_REPORT.md). Следующая инженерная итерация —
|
||||
этап 5, полное завершение Vertical Slice: самостоятельный art/audio pass,
|
||||
lifecycle/save/performance gates и внешний playtest.
|
||||
|
||||
Полный registry этапов 0–10 и правила машинной привязки каждой Bead находятся в
|
||||
[`STAGE_REGISTRY.md`](STAGE_REGISTRY.md). Production-задача четырёх миров и
|
||||
55–70 уровней имеет primary Stage 6 и не остаётся вне нумерованного roadmap.
|
||||
|
||||
Параллельно внешний QA должен выполнить `goon-game-human-playtest`: по две ручные сессии каждого уровня без подсказок разработчика. До получения этого протокола milestone Vertical Slice нельзя называть принятым внешними тестировщиками.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Графы решений уровней 1–10
|
||||
|
||||
Документ фиксирует **предполагаемый авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, а не из ручного прохождения. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения.
|
||||
Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения.
|
||||
|
||||
На 18 августа 2026 года автоматически доказан только уровень 5: `LevelFivePlaythroughTest` управляет production `GameView` через те же флаги движения и прыжка, что и touch-контролы. Все остальные графы имеют статус «предполагаемый, не доказан ботом» до появления воспроизводимого `connectedDebugAndroidTest`.
|
||||
На 18 августа 2026 года intended route всех уровней 1–10 автоматически выполнен по три раза подряд без retry masking. `EarlyLevelPlaythroughTest`, `LevelFivePlaythroughTest`, `LateLevelPlaythroughTest` и `LevelEightPlaythroughTest` управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`.
|
||||
|
||||
## Семантика, общая для графов
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## Уровень 1 — `The Volunteer`
|
||||
|
||||
**Статус:** предполагаемый маршрут, ботом не доказан.
|
||||
**Статус:** доказан ботом, 3/3 последовательных прохождения без смертей.
|
||||
**Ожидаемые смерти:** `0` (`targetDeaths=0`).
|
||||
**Источник:** `LevelCatalog.level1()`; уступы `(640,760,230)` и `(1060,690,250)`; `goal=(1690,770,86,110)`.
|
||||
|
||||
@@ -36,11 +36,11 @@ Goal расположен справа от двух перекрывающих
|
||||
|
||||
**Bot checkpoints:** `playerX>640` при `playerY≤660` → `playerX>1060` при `playerY≤590` → `playerX>1310` → `completed=true`, `deaths=0`.
|
||||
|
||||
**Softlock/неопределённость:** blocker-softlock по данным модели не ожидается; точные окна двух прыжков и возможность альтернативного прохода под/мимо уступов автоматически не проверены.
|
||||
**Softlock/неопределённость:** intended route и окна двух прыжков воспроизводимо подтверждены. Альтернативный проход под/мимо уступов отдельно не проверялся.
|
||||
|
||||
## Уровень 2 — `A Useful End`
|
||||
|
||||
**Статус:** предполагаемый маршрут, ботом не доказан.
|
||||
**Статус:** доказан ботом, 3/3 последовательных прохождения с тремя телами.
|
||||
**Ожидаемые смерти:** `3` (`targetDeaths=3`).
|
||||
**Источник:** `LevelCatalog.level2()`; `SPIKES=(610,850,260,30)`; статичный уступ `(1180,740,240)`; `goal=(1830,770,86,110)`.
|
||||
|
||||
@@ -61,11 +61,11 @@ Spike lane x=610..870 перекрывает наземный маршрут
|
||||
|
||||
**Bot checkpoints:** после каждой предусмотренной жертвы `deaths/corpseCount=1/1`, `2/2`, `3/3`; затем живой игрок `playerX>870`; финально `completed=true`, `deaths=3`.
|
||||
|
||||
**Softlock/неопределённость:** тела становятся горизонтальными коллайдерами и требуют прыжка уже после первой смерти. При текущих `RUN_SPEED`/`JUMP_SPEED` ширина lane может допускать перепрыгивание с меньшим числом смертей; это возможный sequence break, а не подтверждённый маршрут. Нужны бот-проверка intended route и отдельный negative/alternative-solution тест.
|
||||
**Softlock/неопределённость:** intended route с тремя телами воспроизводимо подтверждён. Отдельная попытка прямого zero-death прохода не подтвердила sequence break, но не является исчерпывающим negative-тестом; минимальность трёх смертей остаётся открытым follow-up.
|
||||
|
||||
## Уровень 3 — `Dead Weight`
|
||||
|
||||
**Статус:** предполагаемый маршрут, ботом не доказан.
|
||||
**Статус:** доказан ботом, 3/3 последовательных прохождения с одной жертвой.
|
||||
**Ожидаемые смерти:** `1` (`targetDeaths=1`).
|
||||
**Источник:** `LevelCatalog.level3()`; `SAW=(565,760,92,92)`, impulse `(0,-150)`; `PRESSURE=(540,860,150,20)`, `signalId="gate"`; дверь `(1110,570,78,310)` требует `"gate"`.
|
||||
|
||||
@@ -87,11 +87,11 @@ Door закрывает путь к goal
|
||||
|
||||
**Bot checkpoints:** `deaths=1`, `corpseCount=1`; после respawn `playerAlive=true`; требуется расширить snapshot состоянием `"gate"` либо использовать `allDoorsOpen=true`; затем `playerX>1188` и `completed=true`.
|
||||
|
||||
**Softlock/неопределённость:** успех зависит от фактической точки первого пересечения игрока с `SAW`, потому что тело сохраняет `player.x`; достаточное перекрытие с плитой ботом ещё не доказано. Если тело не удержит плиту, продолжение невозможно без дополнительной жертвы или restart.
|
||||
**Softlock/неопределённость:** доставка тела на плиту, удержание двери и последующий проход подтверждены в трёх последовательных запусках. Альтернативные точки контакта с `SAW` отдельно не перебирались.
|
||||
|
||||
## Уровень 4 — `The Long Lift`
|
||||
|
||||
**Статус:** предполагаемый маршрут, ботом не доказан.
|
||||
**Статус:** доказан ботом, 3/3 последовательных прохождения без смертей.
|
||||
**Ожидаемые смерти:** `0` (`targetDeaths=0`).
|
||||
**Источник:** `LevelCatalog.level4()`; `TOGGLE=(520,820,72,60)`, `signalId="lift"`; moving platform `(1040,790,260,34)`, travel `(0,-390)`, period `5.2`, `controlId="lift"`; `PIT=(1500,880,320,200)`; верхний уступ `(1660,500,540)`; `goal=(1960,390,86,110)`.
|
||||
|
||||
@@ -112,12 +112,12 @@ Goal находится на верхней галерее за PIT
|
||||
|
||||
**Bot checkpoints:** `signal "lift"=true`; координата платформы уменьшается от `y=790`; игрок получает `supportIndex` движущейся платформы; затем `playerX≥1660`, `playerY≤400`; финально `completed=true`, `deaths=0`. Для этого snapshot нужно расширить сигналами, `supportIndex` и состоянием платформы.
|
||||
|
||||
**Softlock/неопределённость:** повторный контакт с `TOGGLE` выключит сигнал и заморозит платформу в текущей фазе. Прыжок от правого края платформы к началу галереи имеет небольшой геометрический запас и не подтверждён ботом; это риск точного тайминга/непроходимости.
|
||||
**Softlock/неопределённость:** lift→gallery прыжок подтверждён в трёх последовательных запусках. Повторный контакт с `TOGGLE` по-прежнему выключает сигнал и может заморозить платформу; это предусмотренное состояние, доступное для restart.
|
||||
|
||||
## Уровень 5 — `Express Delivery`
|
||||
|
||||
**Статус:** автоматически доказан `LevelFivePlaythroughTest` на production `GameView`.
|
||||
**Ожидаемые смерти:** `1` (`targetDeaths=1`); acceptance-тест требует минимум одну.
|
||||
**Статус:** доказан `LevelFivePlaythroughTest`, 3/3 последовательных прохождения.
|
||||
**Ожидаемые смерти:** `1` (`targetDeaths=1`); acceptance-тест требует ровно одну.
|
||||
**Источник:** `LevelCatalog.level5()`; `SAW=(570,760,96,96)`, impulse `(500,-700)`; уступ `(850,800,330)`; `PRESSURE=(930,780,160,20)`, `signalId="gate"`; дверь `(1390,560,82,320)` требует `"gate"`; `goal=(2080,770,86,110)`.
|
||||
|
||||
### Solution graph 5
|
||||
@@ -140,13 +140,13 @@ Door закрывает путь к goal
|
||||
|
||||
**Bot checkpoints:** реализованы в `LevelPlaytestBot.proveLevelFive`: первая смерть; `corpseCount≥1`; `allDoorsOpen=true`; прыжок после `playerX≥390`, дополнительный прыжок после `playerX≥750`; затем `completed=true`. Тест проверяет `completed`, смерть, сохранённое тело и факт открытия двери.
|
||||
|
||||
**Softlock/неопределённость:** основной маршрут воспроизводимо подтверждён одним device-level acceptance-тестом, но пока не выполняется требование roadmap о трёх последовательных прогонах. Альтернативные решения, restart и физическая стабильность на нескольких frame rates этим тестом не покрыты.
|
||||
**Softlock/неопределённость:** основной маршрут воспроизводимо подтверждён тремя последовательными запусками. Альтернативные решения и физическая стабильность на нескольких frame rates этим тестом не покрыты.
|
||||
|
||||
## Уровень 6 — `Cold Comfort`
|
||||
|
||||
**Статус:** предполагаемый маршрут, ботом не доказан.
|
||||
**Статус:** доказан ботом, 3/3 последовательных прохождения с frozen stepping stone.
|
||||
**Ожидаемые смерти:** `1` (`targetDeaths=1`).
|
||||
**Источник:** `LevelCatalog.level6()`; `FREEZER=(690,750,100,130)`; floor заканчивается на `x=1120`; уступы `(810,590,520)` и `(1430,610,570)`; `goal=(1880,500,86,110)`.
|
||||
**Источник:** `LevelCatalog.level6()`; `FREEZER=(690,776,100,104)`; floor заканчивается на `x=1120`; уступы `(810,590,520)` и `(1430,610,570)`; `goal=(1880,500,86,110)`.
|
||||
|
||||
### Solution graph 6
|
||||
|
||||
@@ -166,11 +166,11 @@ Door закрывает путь к goal
|
||||
|
||||
**Bot checkpoints:** `deaths=1`, `corpseCount=1`; после respawn игрок стоит на блоке около `playerY=676`; затем на первом уступе `playerY=490`, `playerX≥810`; на втором `playerY=510`, `playerX≥1430`; финально `completed=true`.
|
||||
|
||||
**Softlock/неопределённость:** маршрут зависит от возможности запрыгнуть на блок рядом с активным `FREEZER`, а затем набрать высоту до уступа; оба перехода ботом не проверены. Frozen block фиксирован детерминированно, поэтому физический дрейф тела не ожидается.
|
||||
**Softlock/неопределённость:** первоначальная зона `FREEZER` перекрывала игрока, стоящего на блоке, на 26 px и делала intended route невозможным. Высота зоны минимально исправлена до `104`; floor runner всё ещё погибает, а оба прыжка подтверждены тремя запусками.
|
||||
|
||||
## Уровень 7 — `Live Wire`
|
||||
|
||||
**Статус:** предполагаемый маршрут, ботом не доказан.
|
||||
**Статус:** доказан ботом, 3/3 последовательных прохождения с conductive corpse.
|
||||
**Ожидаемые смерти:** `1` (`targetDeaths=1`).
|
||||
**Источник:** `LevelCatalog.level7()`; `ELECTRIC=(650,780,110,100)`, `relayId="power"`; дверь `(1130,560,84,320)` требует `"power"`; `goal=(1930,770,86,110)`.
|
||||
|
||||
@@ -193,37 +193,44 @@ Door закрывает путь к goal
|
||||
|
||||
**Bot checkpoints:** `deaths=1`, `corpseCount=1`; `allDoorsOpen=true` после появления conductive corpse; после respawn `playerX>1214`; финально `completed=true`, `deaths=1`. Для прямой проверки механики snapshot должен отдельно сообщать conductive state или активный `"power"`.
|
||||
|
||||
**Softlock/неопределённость:** позиция relay-тела задаётся детерминированно, поэтому потеря сигнала не ожидается. Не доказано, что следующий игрок стабильно перепрыгивает совмещённые hazard/body без дополнительной смерти.
|
||||
**Softlock/неопределённость:** relay-сигнал и прыжок через совмещённые hazard/body подтверждены тремя запусками ровно с одной смертью.
|
||||
|
||||
## Уровень 8 — `Borrowed Time`
|
||||
|
||||
**Статус:** предполагаемый маршрут, ботом не доказан.
|
||||
**Статус:** intended route и восстановление после истечения таймера доказаны production-input ботом, по три раза подряд.
|
||||
**Ожидаемые смерти:** `0` (`targetDeaths=0`).
|
||||
**Источник:** `LevelCatalog.level8()`; `TIMED=(560,810,90,70)`, `signalId="clock"`, duration `5.5`; moving platform `(990,770,240,34)`, travel `(380,0)`, period `4.0`, `controlId="clock"`; `PIT=(900,880,610,200)`; дверь `(1770,560,82,320)` требует `"clock"`; `goal=(2220,600,86,110)`.
|
||||
**Источник:** `LevelCatalog.level8()`; основной `TIMED=(560,810,90,70)`, `signalId="clock"`, duration `8.0`; recovery relay `TIMED=(1600,650,90,70)`, duration `3.5`; moving platform `(910,770,320,34)`, travel `(380,0)`, period `4.0`, `controlId="clock"`; `PIT=(1040,880,470,200)`; дверь `(1770,560,82,320)` требует `"clock"`; `goal=(2220,600,86,110)`.
|
||||
|
||||
### Solution graph 8
|
||||
|
||||
```text
|
||||
PIT разрывает floor, а Door закрывает дальнейший путь
|
||||
→ войти в TIMED и запустить signal "clock" на 5.5 s
|
||||
→ войти в TIMED и запустить signal "clock" на 8.0 s
|
||||
→ "clock" одновременно запускает horizontal moving platform и открывает Door
|
||||
→ без задержки добежать до края floor x=900
|
||||
→ без задержки добежать до края floor x=1040
|
||||
→ прыгнуть на moving platform и пересечь PIT
|
||||
→ сойти на правый floor x≥1510
|
||||
→ пройти Door x=1770..1852 до истечения "clock"
|
||||
→ запрыгнуть на ledge x=1980..2450
|
||||
→ пересечь goal и получить COMPLETE без смерти
|
||||
|
||||
Ветка восстановления после пересечения PIT:
|
||||
→ дождаться истечения "clock" на правом floor x≥1510
|
||||
→ Door закрывается и подтверждает прежнюю trap-state
|
||||
→ намеренно прыгнуть в высокий recovery relay x=1600, y=650
|
||||
→ повторно запустить "clock" на 3.5 s
|
||||
→ пройти Door и достичь goal без смерти/restart
|
||||
```
|
||||
|
||||
**Механика и сигналы:** фронт контакта `TIMED` → `timedSignals["clock"]=5.5` → движение платформы по `travelX=380` и открытие Door; таймер уменьшается каждый update.
|
||||
**Механика и сигналы:** фронт контакта основного `TIMED` → `timedSignals["clock"]=8.0` → движение платформы по `travelX=380` и открытие Door; высокий relay выставляет тот же сигнал на `3.5 s`. Обычный бег проходит под relay, поэтому original route не активирует восстановление автоматически. Android 16 при задержках между решениями выявил узкое окно посадки: launch floor продлён до `x=1040`, а платформа расширена влево до `x=910` при сохранении прежнего правого края `x=1230`, дальнего края PIT `x=1510`, travel, period и recovery relay.
|
||||
|
||||
**Bot checkpoints:** `"clock"` active с remaining time; platform `x>990`; игрок получает её `supportIndex`; затем `playerX≥1510`; Door остаётся open при `playerX>1852`; финально `completed=true`, `deaths=0`. Требуется добавить в snapshot remaining time и координаты платформы.
|
||||
**Bot checkpoints:** основной timer активен; первый прыжок подтверждает `playerOnMovingPlatform=true`, второй доставляет игрока на дальний floor. Перед движением к relay явно подтверждены `playerX≥1510`, `playerY≥779` и всё ещё открытая Door. Intended route проходит Door на первом таймере. Recovery route ждёт `allDoorsOpen=false` справа от PIT, прыжком получает `allDoorsOpen=true`, проходит Door и завершает уровень. Оба маршрута финально дают `completed=true`, `deaths=0`; каждый выполнен три раза подряд на Android 16. Дополнительный intended run с `96 ms` задержки на каждое решение также завершён без смерти. Дальний край платформы намеренно не расширен, чтобы она не могла унести ожидающего игрока обратно над PIT.
|
||||
|
||||
**Softlock/неопределённость:** если `"clock"` истечёт после пересечения PIT, Door закроется, платформа замрёт, а переключатель останется на левой стороне; это потенциальный blocker-softlock до restart. Достаточность 5.5 s и синхронизация прыжка на платформу ботом не доказаны.
|
||||
**Softlock/неопределённость:** прежний blocker устранён: после истечения `"clock"` справа от PIT высокий relay гарантирует restart-free повторное открытие двери. Relay не помогает слева от PIT и не отменяет timed-platform crossing. Оба пути проверены без прямой мутации состояния, teleport, auto-complete или restart.
|
||||
|
||||
## Уровень 9 — `Two Good Reasons`
|
||||
|
||||
**Статус:** предполагаемый маршрут, ботом не доказан.
|
||||
**Статус:** доказан ботом, 3/3 последовательных прохождения с AND из двух тел.
|
||||
**Ожидаемые смерти:** `2` (`targetDeaths=2`).
|
||||
**Источник:** `LevelCatalog.level9()`; `SPIKES=(520,850,130,30)`; `PRESSURE=(510,842,160,38)`, `signalId="weight"`; `ELECTRIC=(910,780,110,100)`, `relayId="power"`; дверь `(1510,540,86,340)` требует `"weight"` и `"power"`; `goal=(2310,770,86,110)`.
|
||||
|
||||
@@ -246,13 +253,13 @@ Final Door использует AND из "weight" и "power"
|
||||
|
||||
**Bot checkpoints:** после первой смерти `deaths/corpses=1/1`, `"weight"=true`, Door ещё closed; после второй `2/2`, `"power"=true`, `allDoorsOpen=true`; после respawn `playerX>1596`; финально `completed=true`, `deaths=2`. Snapshot нужно расширить поимёнными сигналами, чтобы доказать AND, а не только открытие двери.
|
||||
|
||||
**Softlock/неопределённость:** первое тело сохраняет `player.x` в момент контакта со `SPIKES`; нужно подтвердить, что его пересечение с узкой общей зоной PRESSURE стабильно. Если `"weight"` не удерживается, дополнительная spike-смерть может изменить счётчик, но не гарантирует предусмотренное решение.
|
||||
**Softlock/неопределённость:** первая смерть стабильно удерживает `"weight"`, но дверь остаётся закрытой; вторая активирует `"power"` и открывает AND-door. Цепочка подтверждена тремя запусками ровно с двумя телами.
|
||||
|
||||
## Уровень 10 — `The Last Procession`
|
||||
|
||||
**Статус:** предполагаемый mastery-маршрут, ботом не доказан.
|
||||
**Ожидаемые смерти:** `3` (`targetDeaths=3`).
|
||||
**Источник:** `LevelCatalog.level10()`; `SAW=(520,760,96,96)`, impulse `(700,-240)`; `PRESSURE=(860,860,160,20)`, `signalId="lift"`; `FREEZER=(1110,750,100,130)`; `ELECTRIC=(1510,780,110,100)`, `relayId="power"`; moving platform `(1840,790,250,34)`, travel `(0,-410)`, period `5.0`, `controlId="lift"`; `PIT=(1880,880,260,200)`; дверь `(2310,310,82,570)` требует `"power"`; верхний уступ `(2420,440,680)`; `goal=(2880,330,86,110)`.
|
||||
**Статус:** mastery-маршрут доказан production-input ботом: 3/3 последовательных прохождения и ограниченный стресс финального перехода `96 ms` без ретраев.
|
||||
**Ожидаемые и доказанные смерти:** `3` (`targetDeaths=3`).
|
||||
**Источник:** `LevelCatalog.level10()`; `SAW=(520,760,96,96)`, impulse `(700,-240)`; `PRESSURE=(860,860,160,20)`, `signalId="lift"`; `FREEZER=(1110,776,100,104)`; `ELECTRIC=(1510,780,110,100)`, `relayId="power"`; moving platform `(1800,790,290,34)`, travel `(0,-410)`, period `10.0`, `controlId="lift"`; стартовый floor заканчивается на `x=1940`; `PIT=(1940,880,200,200)`; дверь `(2310,310,82,570)` требует `"power"`; верхний уступ `(2420,440,680)`; `goal=(2880,330,86,110)`.
|
||||
|
||||
### Solution graph 10
|
||||
|
||||
@@ -260,28 +267,28 @@ Final Door использует AND из "weight" и "power"
|
||||
Goal расположен на верхнем уступе за PIT и закрытой высокой Door
|
||||
→ первый доброволец входит в SAW
|
||||
→ impulse (700,-240) доставляет тело на PRESSURE x=860..1020
|
||||
→ тело удерживает signal "lift" и запускает вертикальную moving platform
|
||||
→ тело оседает и фиксируется на PRESSURE, удерживает signal "lift" и запускает vertical moving platform
|
||||
→ следующий доброволец погибает в FREEZER
|
||||
→ frozen corpse становится стабильной ступенью для повторного прохода этой зоны
|
||||
→ следующий доброволец погибает в ELECTRIC
|
||||
→ conductive corpse удерживает relay signal "power" и открывает Door
|
||||
→ четвёртый доброволец respawn-ится и пересекает сохранённые тела/опасности
|
||||
→ садится на активную platform у x=1840..2090
|
||||
→ садится на активную platform у x=1800..2090
|
||||
→ поднимается над PIT и в верхней фазе прыгает вправо через открытую Door
|
||||
→ приземляется на ledge x=2420..3100
|
||||
→ достигает goal и получает COMPLETE с тремя смертями
|
||||
```
|
||||
|
||||
**Механика и сигналы:** launched saw corpse → `"lift"` → vertical platform; frozen corpse → твёрдая ступень; conductive corpse → `"power"` → Door open; platform и Door используют независимые постоянные сигналы от сохранённых тел.
|
||||
**Механика и сигналы:** launched saw corpse при контакте с `PRESSURE` обнуляет скорость и закрепляется внутри плиты → `"lift"` → vertical platform; frozen corpse → твёрдая ступень; conductive corpse → `"power"` → Door open; platform и Door используют независимые постоянные сигналы от сохранённых тел.
|
||||
|
||||
**Bot checkpoints:** после SAW `deaths=1`, `"lift"=true`, platform `y<790`; после FREEZER `deaths=2` и наличие frozen corpse; после ELECTRIC `deaths=3`, `"power"=true`, `allDoorsOpen=true`; затем игрок на moving-platform `supportIndex`, в верхней фазе `playerY≤280`; после прыжка `playerX≥2420`, `playerY≤340`; финально `completed=true`. Snapshot должен различать типы тел, оба сигнала, platform position/support.
|
||||
**Bot checkpoints:** после SAW `deaths=1`, `"lift"=true`, platform `y<790`; после FREEZER `deaths=2` и наличие frozen corpse; после ELECTRIC `deaths=3`, `"power"=true`, `allDoorsOpen=true`; перед посадкой первая moving platform должна вернуться к `y≥780`, затем snapshot подтверждает `playerOnMovingPlatform=true`; в верхней фазе `playerY≤280`; после прыжка `playerX≥2420`, `playerY≤340`; финально `completed=true`.
|
||||
|
||||
**Softlock/неопределённость:** обязательность frozen corpse не следует однозначно из текущей геометрии — `FREEZER` потенциально можно перепрыгнуть, сохранив только две необходимые сигнальные жертвы. Доставка saw-тела на плиту зависит от динамического торможения, а прыжок с платформы к ledge имеет небольшой горизонтальный запас. Это два sequence-break/проходимость follow-up, которые нельзя считать решёнными до acceptance-бота.
|
||||
**Softlock/неопределённость:** исправлены два независимых blocker-состояния: saw-тело больше не соскальзывает с плиты после доставки, а перекрытие floor/lift увеличено до 140 px при сохранении 200 px `PIT` и обязательного вертикального подъёма. Период lift увеличен до `10.0 s`, чтобы нижняя фаза была читаемой при задержанном вводе. Бот теперь явно приземляется на frozen corpse и прыгает с него; доставка saw-тела, три сохранённых тела, посадка, подъём и lift→ledge прыжок подтверждены тремя запусками и стресс-сценарием. Возможная альтернативная двухсмертная трасса не считается blocker, пока intended three-body route остаётся стабильным.
|
||||
|
||||
## Проверки, следующие из графов
|
||||
|
||||
1. Обобщить `AutomationSnapshot`: поимённые сигналы, состояния/координаты платформ, `supportIndex`, типы и флаги тел.
|
||||
2. Для уровней 1–4 и 6–10 реализовать production-input acceptance-ботов; каждый intended route выполнить три раза подряд.
|
||||
3. Отдельно проверить предполагаемые sequence break уровня 2 и необязательность `FREEZER` на уровне 10.
|
||||
4. Проверить blocker-softlock уровня 8 после истечения `"clock"` справа от `PIT`.
|
||||
5. Проверить узкие/динамические места: тело на плите уровней 3 и 9, прыжок lift→ledge уровней 4 и 10.
|
||||
1. Сохранять полный integrated device-regression: intended route уровней 1–10 по три раза и отдельный recovery route уровня 8.
|
||||
2. Отдельно проверить предполагаемый sequence break уровня 2 и необязательность `FREEZER` на уровне 10.
|
||||
3. Добавить параметризованные проверки restart из ключевых промежуточных состояний.
|
||||
4. Повторить bot matrix на дополнительных frame rates и поддерживаемых версиях Android.
|
||||
5. Провести ручные сессии этапа 2 и скорректировать подсказки/допуски без ослабления механик.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Stage 2 QA report
|
||||
|
||||
Дата: 18 августа 2026 года. Сборка: `0.4.0-stage2-complete-debug` (`versionCode=5`). Целевая среда приёмки: Android 16, `medium_phone`, `arm64`.
|
||||
|
||||
## Исправленный blocker
|
||||
|
||||
На уровне 10 тело, доставленное пилой на плиту `lift`, продолжало скользить и могло отключить платформу. До исправления focused device-test зафиксировал изменение `firstCorpseX` с `791.4791` до `866.5033` за 750 ms. Теперь заземлённое тело при контакте с `PRESSURE` полностью размещается внутри плиты, его скорости обнуляются, и оно закрепляется. Тест дополнительно ждёт 6 секунд и подтверждает активный lift, ровно одну смерть и одно сохранённое тело.
|
||||
|
||||
## Автоматическая приёмка
|
||||
|
||||
- `testDebugUnitTest`: 17 JVM tests, GREEN;
|
||||
- `lintDebug`: GREEN;
|
||||
- `assembleDebug` и `assembleDebugAndroidTest`: GREEN;
|
||||
- полный unfiltered Android matrix на exact final APK: `OK (16 tests)`, `572.496 s`;
|
||||
- внутри matrix: 36 игровых выполнений, lifecycle и restart;
|
||||
- intended route уровней 1–10 выполняется по три раза без retry masking;
|
||||
- уровень 8: дополнительный stressed run и три restart-free recovery run;
|
||||
- уровень 10: отдельный тест стабильности тела, stressed traversal и три mastery run ровно с тремя телами;
|
||||
- restart уровня 10 очищает тела, сигналы, таймер и удерживаемый ввод.
|
||||
|
||||
## APK verification
|
||||
|
||||
- package: `com.projectsacrifice.game.debug`;
|
||||
- version: `versionCode=5`, `versionName=0.4.0-stage2-complete-debug`;
|
||||
- SDK: `minSdk=23`, `targetSdk=34`;
|
||||
- ZIP integrity: `unzip -t` — no errors;
|
||||
- signature: APK Signature Scheme v1/v2 — verified, one Android Debug signer;
|
||||
- clean install: successful;
|
||||
- cold launch: `Status: ok`, `LaunchState: COLD`, `TotalTime: 972 ms`;
|
||||
- Android crash buffer after launch: empty;
|
||||
- SHA-256: `1bc14020915ebc3b9be4760e0ae83584d8bf2768bbf6e692de24562ec7256bcd`.
|
||||
|
||||
## Граница подтверждения
|
||||
|
||||
Отчёт доказывает инженерную проходимость и стабильность на указанном AVD. Он не подменяет внешний тест понятности: две ручные сессии на каждый уровень без объяснений разработчика остаются задачей `goon-game-human-playtest`. Историческая цель полного matrix менее 5 минут также не достигнута на throttled AVD; exact final набор из 16 методов занимает `9:32.496`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Stage 3 accessibility QA
|
||||
|
||||
Дата: 18 августа 2026 года. Slice: масштаб текста, high contrast, non-audio state
|
||||
и семантика Canvas-экранов.
|
||||
|
||||
## Реализованный контракт
|
||||
|
||||
- Settings содержит сохраняемые пресеты `Text size: Standard/Large`
|
||||
(`1.0×/1.25×`) и `High contrast: Off/On`;
|
||||
- настройки хранятся отдельно от control settings и progress, поэтому миграция не
|
||||
меняет существующие сохранения;
|
||||
- оба общих Canvas text helper применяют выбранный масштаб ко всему тексту;
|
||||
viewport и hit mapping остаются неизменными, а фиксированные HUD/card/button
|
||||
области обрезают переполнение внутри своих границ;
|
||||
- high contrast использует чёрный фон, непрозрачные кнопки, белые контуры и
|
||||
бело-жёлтый текст; активные механизмы не различаются только цветом;
|
||||
- `ON/OFF`, `UP/DOWN`, `OPEN/LOCKED` и постоянная строка
|
||||
`Signals · Doors · Volunteer` дают видимое представление критического состояния
|
||||
при выключенной вибрации;
|
||||
- content description меняется для level select, settings, playing, pause и
|
||||
complete; системный Back сохраняет прежний детерминированный контракт;
|
||||
- read-only automation surface: `textScaleForAutomation()`,
|
||||
`highContrastForAutomation()`, `screenDescriptionForAutomation()`,
|
||||
`criticalVisibleStatusForAutomation()` и `safeViewportForAutomation()`.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
- RED: `AccessibilitySettings` отсутствовал, focused JVM-test завершался шестью
|
||||
`cannot find symbol`;
|
||||
- GREEN: `Standard/Large`, `Off/On` и безопасное восстановление persisted index;
|
||||
- RED → GREEN отдельными циклами: `highContrastEnabled`, `restore`,
|
||||
`canvasTextSize`, `AccessibilityStatus.describe` и
|
||||
`AccessibilityScreenDescription`;
|
||||
- RED device compile: отсутствовали persisted/public Settings surface,
|
||||
screen transition и safe-viewport hooks;
|
||||
- GREEN device compile: пять focused Android acceptance методов компилируются и
|
||||
проверяют recreation, реальные Settings taps, screen/Back semantics,
|
||||
неизменный safe viewport и textual state при `Vibration: Off`.
|
||||
|
||||
## Проверки этого isolated slice
|
||||
|
||||
- focused accessibility JVM tests — GREEN;
|
||||
- все JVM tests — обязательный gate перед коммитом;
|
||||
- `lintDebug`, `assembleDebug`, `compileDebugAndroidTestJavaWithJavac` — обязательный
|
||||
gate перед коммитом;
|
||||
- device execution намеренно оставлен финальной интеграционной ветке: этот stream
|
||||
не запускает и не меняет общий emulator.
|
||||
|
||||
## Интеграционные замечания
|
||||
|
||||
- `GameView` одновременно изменяется onboarding/result и localization slices;
|
||||
при merge нужно сохранить единые text helpers и вызывать refresh semantics после
|
||||
всех новых screen transitions;
|
||||
- localized строки должны заменить английский текст и в Canvas, и в content
|
||||
descriptions одним источником, не удаляя read-only acceptance hooks;
|
||||
- minSdk 23, control settings, progress format и Stage 2 physics/levels не менялись.
|
||||
|
||||
## Финальная интеграция
|
||||
|
||||
Все пять device-методов выполнены в составе unfiltered Android 16 gate:
|
||||
`OK (30 tests)`, `1,409.366 s`. Итоговая сборка и form-factor evidence описаны в
|
||||
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md).
|
||||
@@ -0,0 +1,30 @@
|
||||
# Stage 3 controls QA
|
||||
|
||||
Дата: 18 августа 2026 года. Сборка: `0.5.0-stage3-controls-debug` (`versionCode=6`). Активная Beads-задача: `goon-game-controls-settings`.
|
||||
|
||||
## Реализованный slice
|
||||
|
||||
- Settings screen доступен с level-select screen и возвращается назад системной кнопкой или кнопкой `Back`;
|
||||
- control size: `Small` (`0.85×`), `Medium` (`1.0×`), `Large` (`1.2×`);
|
||||
- control opacity: `Low` (`0.45`), `Medium` (`0.65`), `High` (`0.9`);
|
||||
- vibration: `On`/`Off`; все игровые haptic events проходят через сохранённую настройку;
|
||||
- размер одновременно меняет визуальный радиус и фактический touch target;
|
||||
- значения сохраняются через отдельный versioned `SharedPreferences` store и загружаются новой Activity.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
- RED: отсутствовал `ControlSettings`; unit-test не компилировался;
|
||||
- GREEN: cycling и безопасные defaults size/opacity/vibration;
|
||||
- RED: Android persistence contract не компилировался без public Settings surface;
|
||||
- GREEN: реальные taps меняют три настройки, Activity recreation загружает `Large`/`High`/`Off`;
|
||||
- device regression: точка вне `Medium`, но внутри `Large`, активирует production touch input;
|
||||
- device regression: удерживаемый jump выполняется один раз и не срабатывает повторно после приземления.
|
||||
|
||||
## Form factors и регрессии
|
||||
|
||||
- 20:9 physical AVD (`2400×1080`, density `420`): Settings test `OK (3 tests)`;
|
||||
- logical 16:9 (`1920×1080`): persistence/touch UI `OK (1 test)`;
|
||||
- tablet-sized logical display (`2560×1600`, density `320`): persistence/touch UI `OK (1 test)`;
|
||||
- existing lifecycle, clean restart, Level 5 three-run bot и Level 10 retained-body test: `OK (4 tests)`.
|
||||
|
||||
Логические display overrides проверяют масштабирование и hit mapping на одном Android 16 AVD; реальное tablet hardware остаётся частью последующего device-matrix этапа.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Stage 3 RU/EN localization QA
|
||||
|
||||
Date: 2026-08-18
|
||||
Scope: persistent, explicit English/Russian language selection for the Canvas UI.
|
||||
|
||||
## Delivered contract
|
||||
|
||||
- English is the default for missing, null, or unknown stored language codes.
|
||||
- The Settings screen exposes a dedicated English/Russian switch without moving the
|
||||
existing control-size, opacity, vibration, or Back touch targets.
|
||||
- The selected language is stored separately from progress and control preferences
|
||||
and is restored when `MainActivity` is recreated.
|
||||
- `GameStrings` owns all player-facing copy: level select, settings, HUD, respawn,
|
||||
pause, completion, buttons, statistics, ten level titles and hints.
|
||||
- Result-flow and onboarding copy has typed RU/EN hooks ready for the other Stage 3
|
||||
slices; new Canvas code does not need language conditionals.
|
||||
- Long localized hints, card titles, and best-result summaries are fitted to their
|
||||
existing Canvas bounds. Gameplay geometry and Android `minSdk 23` are unchanged.
|
||||
- Automation exposes current/set/toggle language and a read-only snapshot of the
|
||||
localized copy rendered by the current screen.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
1. RED: the fallback test failed compilation with seven missing `GameLanguage` /
|
||||
`GameStrings` symbols. GREEN: English default and safe unknown/null fallback.
|
||||
2. RED: the complete Russian-pack test failed compilation with ten missing copy
|
||||
methods. GREEN: complete RU pack, ten titles/hints, statistics, plural forms,
|
||||
result hooks, and onboarding hooks.
|
||||
3. RED: the persistence device test failed compilation with four missing GameView
|
||||
language hooks. GREEN: Settings toggle plus a dedicated preference store.
|
||||
4. RED: the integrated acceptance extension failed compilation with three missing
|
||||
setter/snapshot hooks. GREEN: explicit language setter and current-screen copy
|
||||
snapshot.
|
||||
|
||||
## Automated checks
|
||||
|
||||
| Gate | Result |
|
||||
| --- | --- |
|
||||
| `testDebugUnitTest` | GREEN, 22/22 JVM tests |
|
||||
| `lintDebug` | GREEN |
|
||||
| `assembleDebug` | GREEN |
|
||||
| `compileDebugAndroidTestJavaWithJavac` | GREEN, including `GameLanguagePersistenceTest` |
|
||||
| Canvas raw-copy audit | GREEN; only language-neutral `II` and numeric formats remain in draw calls |
|
||||
|
||||
`GameLanguagePersistenceTest` is compiled here but intentionally not executed in
|
||||
this isolated stream because emulator ownership belongs to final Stage 3 integration.
|
||||
It performs real Settings taps and verifies Russian visible-copy snapshot, Activity
|
||||
recreation, progress, control size, opacity, and vibration preservation.
|
||||
|
||||
## Integration notes
|
||||
|
||||
- `GameView.java` will conflict with accessibility/onboarding/result branches. Keep
|
||||
their UI behavior and route every new visible string through the existing
|
||||
`GameStrings` result/onboarding hooks.
|
||||
- `ControlSettings.java` is not modified; localized preset labels are derived from
|
||||
its stable package-visible preset indexes.
|
||||
- `LevelCatalog.java` and level geometry are not modified. Canvas rendering now uses
|
||||
`GameStrings.levelTitle/levelHint`; the existing English fields remain compatible
|
||||
with gameplay tests and older callers.
|
||||
|
||||
## Final integration
|
||||
|
||||
`GameLanguagePersistenceTest` and the combined Stage 3 RU/EN acceptance tests passed
|
||||
inside the unfiltered Android 16 gate: `OK (30 tests)`, `1,409.366 s`. See
|
||||
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md) for the release evidence.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Stage 3 onboarding and result QA
|
||||
|
||||
Дата: 18 августа 2026 года. Базовая сборка: `0.5.0-stage3-controls-debug`
|
||||
(`minSdk=23`). Этот документ описывает независимый slice обучения и результата;
|
||||
версия финального Stage 3 APK будет назначена после интеграции всех веток.
|
||||
|
||||
## Реализовано
|
||||
|
||||
- при первом входе в уровень показывается компактная карточка из трёх подсказок:
|
||||
движение, одновременные движение и прыжок двумя пальцами, использование тел;
|
||||
- карточка не останавливает физику и не отключает экранное управление;
|
||||
- доступны `Skip`, пошаговый `Next` и финальный `Done`;
|
||||
- закрытие сохраняется в отдельном versioned storage
|
||||
`project_sacrifice_onboarding_v1` и не изменяет progress/control settings;
|
||||
- результат разделяет показатели текущего прохождения и сохранённые лучшие время
|
||||
и число добровольцев;
|
||||
- результат показывает оба target и выбирает понятную следующую цель: улучшить
|
||||
число добровольцев, время, перейти на следующий уровень либо улучшить personal best;
|
||||
- `Level select`, `Replay` и `Next level` используют общий navigation contract и
|
||||
не очищают прогресс или настройки управления.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
- `OnboardingFlowTest`: RED — `cannot find symbol: class OnboardingFlow`; GREEN — 2/2;
|
||||
- `CompletionResultTest`: RED — `cannot find symbol: class CompletionResult`; GREEN — 2/2;
|
||||
- `OnboardingStoreTest`: RED — `cannot find symbol: class OnboardingStore`; после
|
||||
реализации AndroidTest compile GREEN;
|
||||
- `OnboardingAndResultFlowTest`: RED — отсутствовали публичные GameView contracts
|
||||
обучения/result navigation; после интеграции AndroidTest compile GREEN.
|
||||
|
||||
## Выполненные проверки
|
||||
|
||||
```text
|
||||
./gradlew testDebugUnitTest lintDebug assembleDebug compileDebugAndroidTestJavaWithJavac
|
||||
BUILD SUCCESSFUL
|
||||
```
|
||||
|
||||
- JVM: 24/24, failures=0, errors=0;
|
||||
- `lintDebug`: GREEN;
|
||||
- `assembleDebug`: GREEN;
|
||||
- все Android instrumentation sources: compile GREEN;
|
||||
- `git diff --check`: GREEN.
|
||||
|
||||
По ограничению stream-задачи emulator/connected tests в этой ветке не запускались.
|
||||
После интеграции необходимо выполнить `OnboardingStoreTest` и
|
||||
`OnboardingAndResultFlowTest` на Android 16 вместе с полным Stage 2 regression gate.
|
||||
|
||||
## Приёмка на устройстве после интеграции
|
||||
|
||||
1. Очистить данные приложения, открыть уровень 1 и убедиться, что карточка не
|
||||
мешает одновременно удерживать направление и нажимать Jump.
|
||||
2. Проверить отдельно `Skip` и полный путь `Next` → `Next` → `Done`, затем cold
|
||||
launch: обучение не должно появиться повторно.
|
||||
3. Завершить уровень хуже сохранённого результата: `This run` не должен подменять
|
||||
`Saved best`.
|
||||
4. По очереди проверить `Level select`, `Replay`, `Next level`; завершённость
|
||||
уровня, unlocked level и control preset должны сохраниться.
|
||||
|
||||
## Финальная интеграция
|
||||
|
||||
`OnboardingStoreTest`, оба `OnboardingAndResultFlowTest` и объединённый RU/EN
|
||||
acceptance прошли в составе unfiltered Android 16 gate: `OK (30 tests)`,
|
||||
`1,409.366 s`. Релизная версия назначена `0.6.0-stage3-complete`; общий отчёт:
|
||||
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md).
|
||||
@@ -0,0 +1,93 @@
|
||||
# Stage 3 QA report
|
||||
|
||||
Дата: 18 августа 2026 года.
|
||||
|
||||
Версия: `0.6.0-stage3-complete` (`versionCode=7`).
|
||||
|
||||
## Результат
|
||||
|
||||
Инженерный scope этапа 3 завершён. В общую сборку интегрированы:
|
||||
|
||||
- сохраняемые размер и прозрачность touch-controls и режим вибрации;
|
||||
- Standard/Large text, high contrast, видимые состояния сигналов, дверей и respawn;
|
||||
- локализованные accessibility descriptions;
|
||||
- полная RU/EN локализация Canvas UI, десяти названий и подсказок уровней;
|
||||
- неблокирующее трёхшаговое обучение с постоянным Skip/Done;
|
||||
- result screen с текущим и лучшим результатом, targets, следующей целью и
|
||||
рабочими переходами Levels/Replay/Next;
|
||||
- совместная сохранность языка, accessibility, controls и прогресса после recreate;
|
||||
- state-driven боты уровней 1–10, restart-free recovery уровня 8 и проверка
|
||||
неподвижного тела на lift-плите уровня 10.
|
||||
|
||||
## Автоматические проверки
|
||||
|
||||
Финальный clean gate:
|
||||
|
||||
```text
|
||||
./gradlew clean testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest
|
||||
BUILD SUCCESSFUL
|
||||
66 actionable tasks
|
||||
42/42 JVM tests
|
||||
```
|
||||
|
||||
Финальный unfiltered device gate выполнен на холодно запущенном Android 16 AVD,
|
||||
физический размер `1080x2400` (20:9), после установки точных APK clean-сборки и
|
||||
`pm clear com.projectsacrifice.game.debug`:
|
||||
|
||||
```text
|
||||
AndroidJUnitRunner
|
||||
Tests run: 30
|
||||
Time: 1,409.366 s
|
||||
OK (30 tests)
|
||||
```
|
||||
|
||||
Runner запускался без `-e class`, retries и повторного запуска отдельных методов.
|
||||
Матрица включает все intended routes уровней 1–10 по три раза, дополнительные
|
||||
stressed/recovery маршруты уровня 8, throttled mastery и sliding-body regression
|
||||
уровня 10, lifecycle, restart и все Stage 3 persistence/UI contracts.
|
||||
|
||||
До финального объединения UI contracts также прошли на логических viewport:
|
||||
|
||||
- 16:9 safe viewport — GREEN, `66.208 s`;
|
||||
- 16:10 tablet-sized `2560x1600` landscape — GREEN, `167.823 s`;
|
||||
- focused Stage 3 matrix — 14/14 GREEN, `576.355 s`.
|
||||
|
||||
Последующие изменения касались automation/read-only snapshot и не меняли UI
|
||||
геометрию; финальный 20:9 gate повторно проверил весь интегрированный интерфейс.
|
||||
|
||||
## RED → GREEN evidence
|
||||
|
||||
Первый полный 30-тестовый прогон был завершён полностью и дал 27/30 GREEN. Три
|
||||
нагрузочные границы были исправлены без изменения физики уровней или retry:
|
||||
|
||||
1. Level 3: takeoff начинался на несколько пикселей поздно (`x=505.67633`,
|
||||
`y=664.41797`, лишняя смерть). State-driven точка старта получила запас и
|
||||
повторно прошла 3/3 внутри полного load-order.
|
||||
2. Level 6: координатный checkpoint не доказывал опору на frozen block; добавлен
|
||||
обязательный `playerOnGround`, после чего landing route прошёл 3/3.
|
||||
3. Level 10: бот мог прыгнуть на лифт во время его восходящей фазы и упасть в PIT
|
||||
(`x=1942.7063`, deaths=4). Snapshot расширен read-only направлением движения;
|
||||
посадка разрешается на нисходящей фазе, normal 3/3 и throttled route GREEN.
|
||||
|
||||
Level 8 отдельно получил исходящую фазу горизонтальной платформы, безопасное
|
||||
позиционирование под recovery relay и перед финальным прыжком. Финальный suite
|
||||
подтвердил stressed 1/1, intended 3/3 и recovery 3/3 без смерти/restart.
|
||||
|
||||
## Проверка APK
|
||||
|
||||
Финальный `app-debug.apk` проверен как самостоятельный устанавливаемый артефакт:
|
||||
|
||||
- ZIP/APK-структура: `unzip -t` без ошибок;
|
||||
- package: `com.projectsacrifice.game.debug`;
|
||||
- версия: `versionCode=7`, `versionName=0.6.0-stage3-complete-debug`;
|
||||
- `minSdkVersion=23`, `targetSdkVersion=34`;
|
||||
- подпись: `apksigner verify` GREEN, схемы v1 и v2;
|
||||
- SHA-256: `7be25ea96cf0de7ea1930335b3570bd9def93cdb48029e24e4c9c14078e4195d`;
|
||||
- clean install и cold launch на Android 16: `Status: ok`, `LaunchState: COLD`,
|
||||
`TotalTime: 1401 ms`; crash-буфер пуст.
|
||||
|
||||
## Границы приёмки
|
||||
|
||||
Инженерный Stage 3 gate закрыт. Отдельная задача ручного внешнего playtest остаётся
|
||||
открытой: реальные тестировщики должны пройти каждый уровень без подсказок
|
||||
разработчика. Этот внешний протокол не подменяется автоматическими ботами.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Stage 4: формат уровней и контентные инструменты
|
||||
|
||||
## Назначение
|
||||
|
||||
Все десять уровней находятся в Android-independent модуле `level-format` и
|
||||
загружаются из `level-format/src/main/resources/levels`. Runtime, unit-тесты и
|
||||
desktop-команды используют один и тот же JSON parser, catalog loader и validator.
|
||||
Изменение геометрии или текста не требует изменения игрового Java-кода.
|
||||
|
||||
Текущие версии контракта:
|
||||
|
||||
- `formatVersion: 1` — схема JSON;
|
||||
- `physicsVersion: 1` — набор физических допущений, для которого создан уровень;
|
||||
- bot `traceVersion: 1` — формат журнала acceptance-команд.
|
||||
|
||||
Неизвестная версия отклоняется. Поддержка новой версии должна добавляться явно,
|
||||
вместе с migration/tests, без молчаливого fallback.
|
||||
|
||||
## Каталог
|
||||
|
||||
`levels/index.json` задаёт точный порядок уровней и содержит те же версии:
|
||||
|
||||
```json
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"physicsVersion": 1,
|
||||
"levels": ["level-01.json", "level-02.json"]
|
||||
}
|
||||
```
|
||||
|
||||
Имена в каталоге должны быть уникальны, ресурсы должны существовать, а поле
|
||||
`number` каждого загруженного уровня должно соответствовать позиции в списке.
|
||||
|
||||
## Схема уровня
|
||||
|
||||
Минимальный характерный пример:
|
||||
|
||||
```json
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"physicsVersion": 1,
|
||||
"number": 3,
|
||||
"copy": {
|
||||
"en": {"title": "Dead Weight", "hint": "Leave a body on the plate."},
|
||||
"ru": {"title": "Мёртвый груз", "hint": "Оставьте тело на плите."}
|
||||
},
|
||||
"world": {"width": 2050, "height": 1080},
|
||||
"spawn": {"x": 130, "y": 760},
|
||||
"goal": {"x": 1810, "y": 770, "width": 86, "height": 110},
|
||||
"targets": {"deaths": 1, "seconds": 50},
|
||||
"platforms": [
|
||||
{"box": {"x": 0, "y": 880, "width": 2050, "height": 200}}
|
||||
],
|
||||
"hazards": [
|
||||
{"type": "SAW", "box": {"x": 565, "y": 760, "width": 92, "height": 92},
|
||||
"impulseX": 0, "impulseY": -150}
|
||||
],
|
||||
"switches": [
|
||||
{"type": "PRESSURE", "box": {"x": 540, "y": 860, "width": 150, "height": 20},
|
||||
"signalId": "gate", "durationSeconds": 0, "requiredMass": 1}
|
||||
],
|
||||
"doors": [
|
||||
{"box": {"x": 1110, "y": 570, "width": 78, "height": 310},
|
||||
"requiredSignals": ["gate"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Поддерживаемые значения:
|
||||
|
||||
- hazards: `SPIKES`, `SAW`, `FREEZER`, `ELECTRIC`, `PIT`;
|
||||
- switches: `PRESSURE`, `TOGGLE`, `TIMED`;
|
||||
- static platform: только `box`;
|
||||
- moving platform: `box`, ненулевой `travelX` или `travelY`, положительный
|
||||
`periodSeconds`, опциональный `controlId`;
|
||||
- electrical hazard может публиковать `relayId`;
|
||||
- door перечисляет все `requiredSignals` (AND-семантика).
|
||||
|
||||
## Валидация
|
||||
|
||||
Валидатор проверяет до упаковки APK:
|
||||
|
||||
- известные format/physics versions;
|
||||
- конечные положительные размеры мира и объектов;
|
||||
- spawn, goal, платформы, hazards, switches, doors и конечную позицию moving
|
||||
platform внутри мира;
|
||||
- наличие хотя бы одной платформы;
|
||||
- неотрицательный target deaths и положительный target seconds;
|
||||
- непустой signal ID, положительный required mass и положительную длительность
|
||||
`TIMED`;
|
||||
- существование каждого `door.requiredSignals` и `platform.controlId` среди
|
||||
сигналов switches/relays;
|
||||
- целостность index, уникальность ресурсов и последовательную нумерацию.
|
||||
|
||||
Ошибка содержит путь, например:
|
||||
|
||||
```text
|
||||
level-03.json.doors[0].requiredSignals[0] references missing signal 'missing'
|
||||
```
|
||||
|
||||
Команда:
|
||||
|
||||
```sh
|
||||
./gradlew :level-format:validateLevels
|
||||
```
|
||||
|
||||
`app:preBuild` зависит от неё. Для проверки временной копии каталога можно
|
||||
передать `-PlevelDirectory=/absolute/path/to/levels`.
|
||||
|
||||
## Детерминированный preview
|
||||
|
||||
```sh
|
||||
./gradlew :level-format:previewLevels
|
||||
```
|
||||
|
||||
Результат находится в `level-format/build/level-previews`:
|
||||
|
||||
- `level-01.svg` … `level-10.svg` — world-space схема spawn/goal, платформ,
|
||||
hazards, switches и doors;
|
||||
- `catalog.tsv` — координаты, размеры, travel и signal/control references.
|
||||
|
||||
Вывод использует стабильный порядок и locale-independent числа. Два запуска на
|
||||
неизменном каталоге должны давать byte-identical файлы.
|
||||
|
||||
## Bot trace
|
||||
|
||||
Каждый generic и level-8 acceptance-run возвращает `traceJson()` и `traceId()`.
|
||||
Trace содержит:
|
||||
|
||||
- `traceVersion`, level, `levelFormatVersion`, `physicsVersion` и scenario;
|
||||
- outcome;
|
||||
- события `START`, `INPUT`, `CHECKPOINT`, `FINISH`;
|
||||
- только simulation time, публичный input и наблюдаемое состояние игрока/дверей.
|
||||
|
||||
Wall-clock, device identifiers и прямые mutation-команды не записываются.
|
||||
`traceId` — SHA-256 точного UTF-8 JSON и всегда включён в failure diagnostic.
|
||||
Stage 4 сохраняет воспроизводимый артефакт расследования, но намеренно не добавляет
|
||||
runtime replay executor.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Stage 4 QA report
|
||||
|
||||
Дата: 18 августа 2026 года
|
||||
Версия: `0.7.0-stage4-content` (`versionCode 8`)
|
||||
|
||||
## Результат
|
||||
|
||||
Этап 4 «Контентная архитектура и инструменты» завершён инженерно:
|
||||
|
||||
- уровни 1–10 перенесены из Java-констант в versioned JSON;
|
||||
- Android runtime и desktop tools используют общий pure-Java loader/validator;
|
||||
- build отклоняет повреждённый контент до APK packaging;
|
||||
- deterministic SVG/TSV preview экспортирует все десять уровней;
|
||||
- acceptance bots формируют versioned JSON trace и SHA-256 trace ID;
|
||||
- исходная геометрия, физика, тексты и gameplay progression сохранены;
|
||||
- полный накопительный Android regression прошёл без filters/retries.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
Последовательные RED→GREEN slices:
|
||||
|
||||
1. отсутствовали loader и versioned fields → добавлен JSON loader и immutable model;
|
||||
2. неизвестный format принимался → fail-closed version contract;
|
||||
3. ссылка двери на отсутствующий signal не диагностировалась → path-specific reference check;
|
||||
4. platform с нулевой шириной принималась → geometry/world-bounds validation;
|
||||
5. parser не поддерживал полный набор механик → vocabulary всех текущих уровней;
|
||||
6. отсутствовал index/catalog loader → ordered resource catalog;
|
||||
7. RU copy возвращала EN fallback → localized copy перенесена в JSON;
|
||||
8. отсутствовала desktop-команда → validate/preview tool;
|
||||
9. preview command возвращала usage → byte-identical SVG/TSV output;
|
||||
10. отсутствовала Gradle task → validation wired before `app:preBuild`;
|
||||
11. отсутствовал versioned bot trace → deterministic Result trace + SHA-256 ID;
|
||||
12. bespoke level-8 runner не трассировался → reusable tracing driver;
|
||||
13. `TIMED` с нулевой длительностью принимался → positive duration validation.
|
||||
|
||||
Отдельно доказан build-time отказ на временной копии каталога с ошибкой:
|
||||
|
||||
```text
|
||||
level-03.json.doors[0].requiredSignals[0] references missing signal 'missing'
|
||||
```
|
||||
|
||||
Канонический каталог при этом не изменялся.
|
||||
|
||||
## Автоматические проверки
|
||||
|
||||
Integrated JVM/build gate:
|
||||
|
||||
```sh
|
||||
./gradlew :level-format:test testDebugUnitTest lintDebug \
|
||||
assembleDebug assembleDebugAndroidTest :level-format:previewLevels
|
||||
```
|
||||
|
||||
Результат: `BUILD SUCCESSFUL`; parser, catalog, validator, tooling, localization,
|
||||
bot trace и прежние unit contracts GREEN. Выполнено 52 app JVM-теста и 2
|
||||
`level-format` теста, failures/errors = 0. APK содержит `levels/index.json` и
|
||||
десять JSON-файлов уровней.
|
||||
|
||||
Device acceptance:
|
||||
|
||||
- AVD: `medium_phone`, Android 16 / API 36, 1080×2400;
|
||||
- команда: unfiltered `am instrument` всего debug test APK;
|
||||
- результат: `OK (30 tests)`;
|
||||
- wall time: `1,096.448 s`;
|
||||
- один процесс, без class/method filters и retries;
|
||||
- GREEN: intended routes 1–10, level-8 stress/recovery, level-10 delayed
|
||||
traversal, level-5, lifecycle, restart, controls, accessibility, RU/EN,
|
||||
onboarding/result и Stage 3 acceptance contracts.
|
||||
|
||||
Первая попытка запуска на принудительном CPU SwiftShader была остановлена как
|
||||
неограниченная ошибка среды: эмулятор расходовал около 678% host CPU и дошёл
|
||||
только до второго теста за восемь минут. Она не содержала product failure и не
|
||||
считается RED/GREEN или retry. Финальный результат получен на стандартном
|
||||
host-rendered профиле AVD.
|
||||
|
||||
Final APK inspection:
|
||||
|
||||
- `unzip -t`: ошибок нет;
|
||||
- package: `com.projectsacrifice.game.debug`;
|
||||
- `versionCode=8`, `versionName=0.7.0-stage4-content-debug`;
|
||||
- `apksigner`: v1=true, v2=true, один стандартный Android Debug signer;
|
||||
- clean install: `Success`;
|
||||
- cold start: `Status: ok`, `LaunchState: COLD`, 401 ms;
|
||||
- `MainActivity`: `RESUMED`, visible, reportedDrawn;
|
||||
- Android crash buffer после запуска пуст.
|
||||
|
||||
## Внешние gates
|
||||
|
||||
Как и в предыдущих этапах, automation доказывает техническую проходимость, но не
|
||||
понятность головоломок новому игроку. Bead `goon-game-human-playtest` остаётся
|
||||
открытым: требуются две независимые ручные сессии на каждый уровень.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Stage 5 external playtest protocol
|
||||
|
||||
This protocol defined the final Stage 5 acceptance gate. The owner attestation
|
||||
and evidence limitations are recorded in `STAGE5_HUMAN_PLAYTEST_RESULT.md`.
|
||||
|
||||
## 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.
|
||||
|
||||
Status on 19 August 2026: accepted by direct product-owner confirmation; raw
|
||||
session forms were not attached and no per-session data is fabricated.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Stage 5 external playtest result
|
||||
|
||||
Date: 19 August 2026
|
||||
Candidate: `0.8.0-stage5-vslice-debug`, versionCode 9
|
||||
APK SHA-256: `8bb970c7385a48d8002907382067a5f8f87554a33ad7aaace9aa593194c8fed5`
|
||||
|
||||
## Acceptance record
|
||||
|
||||
The product owner confirmed in the project task that external testing had
|
||||
finished and explicitly directed development to proceed beyond Stage 5. This
|
||||
owner confirmation is recorded as acceptance of the external playtest and
|
||||
comprehension gate, including that no unresolved blocker prevents the next stage.
|
||||
|
||||
Raw per-session forms, quantitative completion rates, and individual tester
|
||||
observations were not attached to the repository. Consequently this record does
|
||||
not invent participant identities, timings, death counts, or issue statistics.
|
||||
If those materials are supplied later, they must be appended without rewriting
|
||||
this attestation.
|
||||
|
||||
Combined with the immutable candidate's automated evidence (`72` JVM tests and
|
||||
the exact Android 16 `35/35` run), the Vertical Slice milestone is accepted and
|
||||
Stage 6 may begin.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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. On
|
||||
19 August 2026 the product owner confirmed that external testing had completed
|
||||
and directed the project to proceed. `STAGE5_HUMAN_PLAYTEST_RESULT.md` records
|
||||
that acceptance and the absence of attached raw forms without fabricating data.
|
||||
The Vertical Slice milestone is accepted.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user