Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243da9185e | ||
|
|
f53e068cb1 | ||
|
|
4d8aa28f0a | ||
|
|
b31c2ea720 | ||
|
|
4869649b47 | ||
|
|
3de8d45039 | ||
|
|
259fcd316b | ||
|
|
19bb64edfb | ||
|
|
dc2ebcbf8c | ||
|
|
cc7f487cf7 | ||
|
|
cc3b5902cc | ||
|
|
62ee6877f9 | ||
|
|
0943caa3cc | ||
|
|
dcb7c2d497 | ||
|
|
3527ee5bd6 | ||
|
|
cde886ca96 | ||
|
|
b840684ef3 | ||
|
|
093fb63ca6 | ||
|
|
5051f316fe | ||
|
|
2ddac2775f | ||
|
|
1e9520dc14 | ||
|
|
55b90fb0a5 | ||
|
|
fa68e2e04a | ||
|
|
9d792bdee4 | ||
|
|
a8d72024f2 | ||
|
|
f06467fcfa | ||
|
|
f07d1cf830 | ||
|
|
da3777b4cc | ||
|
|
650bfb3533 | ||
|
|
88a1ba9b34 | ||
|
|
de3b9485c1 | ||
|
|
21775542d5 | ||
|
|
dcb16ef4d0 | ||
|
|
00323d7916 |
@@ -24,3 +24,26 @@
|
||||
{"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
-72
@@ -1,72 +1,78 @@
|
||||
{"_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-18T10:41:47Z","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.","labels":["bug","current","level10","physics"],"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-18T01:30:37Z","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.","labels":["bug","current","freeze","level6"],"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-18T01:30:42Z","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.","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 минут каждый.","notes":"2026-08-18: one final integrated Android 16 run is green: 14/14 methods, 35 gameplay executions plus lifecycle, Gradle wall time 5:59. Correctness is proven, but acceptance remains open because the \u003c5:00 target is missed by 59s and three full matrix repetitions are not yet recorded.\n2026-08-18 final Stage 2 candidate: one exact unfiltered Android 16 run passed 16/16 methods, 36 gameplay executions plus lifecycle/restart in 479.619 s. Correctness gate is green; historical \u003c5:00 and three full exact-candidate repetitions remain open as runner-performance evidence.\n2026-08-18 exact versionCode 5 clean-build matrix: OK 16/16, 36 gameplay executions plus lifecycle/restart, 572.496 s, no retries. APK installed over previous build before the run.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":480,"created_at":"2026-08-17T21:40:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T10:52:43Z","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":"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-18T01:30:46Z","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.","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":"{}"},{"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-18T01:30:34Z","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.","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 уровней проходят три раза подряд и понятны внешнему тестировщику.","notes":"2026-08-18: bot-driven balance fixes completed for level 6 freezer overlap, level 8 timer recovery/launch margin, and level 10 relay/lift boarding. Intended routes 1–10 pass 3x in one integrated matrix. Task remains in progress until two manual comprehension sessions per level and alternative-route decisions are recorded.\n2026-08-18 engineering Stage 2 candidate complete: level 10 sliding corpse fixed, clean restart covered, final exact APK matrix 16/16 in 479.619 s. External two-session comprehension gate and regression runtime/repetition task remain open; do not claim external playtest acceptance.\n2026-08-18 exact versionCode 5 full matrix also GREEN 16/16 in 572.496 s. Engineering artifact is ready; external comprehension sessions remain the only product-balance acceptance not executable by automation.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T10:52:44Z","started_at":"2026-08-18T01:31:03Z","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-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-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 раза, а допустимые альтернативы явно зафиксированы или геометрия исправлена.","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-18T10:41:48Z","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.","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 и планшете.","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-18T11:43:04Z","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.","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":"closed","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:50Z","closed_at":"2026-08-19T03:32:50Z","close_reason":"All EPIC-02 children complete: death/respawn core, CorpseManager ownership, and every approved corpse state has deterministic runtime and puzzle evidence.","metadata":{"stages":"0,6,7","primary_stage":6},"labels":["corpse","death","epic","primary-stage-6","stage-0","stage-6","stage-7"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-plan","title":"Project Sacrifice: выполнить утверждённый production plan","description":"Главный bead для плана из docs/DEVELOPMENT_PLAN.md: vertical slice, production, alpha, beta и release candidate.","acceptance_criteria":"Release Candidate удовлетворяет критериям раздела 59; все дочерние эпики завершены либо явно исключены решением владельца продукта.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:23Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:51Z","started_at":"2026-08-17T21:41:28Z","metadata":{"stages":"0,1,2,3,4,5,6,7,8,9,10","primary_stage":10},"labels":["gdd","master","primary-stage-10","production","stage-0","stage-1","stage-10","stage-2","stage-3","stage-4","stage-5","stage-6","stage-7","stage-8","stage-9","tdd"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-stage4-bot-traces","title":"Сохранять воспроизводимый журнал команд бота","description":"Добавить versioned trace contract для production-input сценариев с level/format/physics versions, simulation timestamps, inputs и ключевыми checkpoints.","design":"Trace recorder наблюдает публичный Driver/Input контракт и не управляет GameView напрямую; replay engine вне Stage 4 scope, журнал должен быть пригоден для последующего replay.","acceptance_criteria":"Каждый bot runner может выдать deterministic JSON trace; повторная сериализация идентична; timeout diagnostics указывает trace; sensitive/device-local data не записываются.","notes":"GREEN: trace v1 records simulation inputs/checkpoints/outcome plus format/physics versions; SHA-256 trace ID is included in diagnostics; generic and level-8 runners covered.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:04Z","started_at":"2026-08-18T19:01:26Z","closed_at":"2026-08-18T19:01:34Z","close_reason":"Deterministic versioned traces and diagnostic IDs implemented for all bot runner families.","metadata":{"stages":4,"primary_stage":4},"labels":["bot","diagnostics","primary-stage-4","replay","stage-4","stage4"],"dependencies":[{"issue_id":"goon-game-stage4-bot-traces","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:32Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-stage4-validator-tools","title":"Добавить build validator и desktop preview/export","description":"Создать запускаемый на desktop инструмент, который валидирует каталог до Android-сборки и экспортирует координаты в читаемый SVG/текстовый отчёт.","design":"Инструмент использует ту же pure-Java загрузку и validator, что runtime, без дублирования правил в Gradle или Python.","acceptance_criteria":"Gradle build отклоняет broken fixture до упаковки APK; CLI валидирует все levels и создаёт deterministic preview/export; команда и формат задокументированы.","notes":"GREEN: preBuild validateLevels, path-specific broken-signal rejection, deterministic 10 SVG + catalog.tsv preview.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:28Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:04Z","started_at":"2026-08-18T19:01:11Z","closed_at":"2026-08-18T19:01:21Z","close_reason":"Shared build validator and deterministic desktop preview/export implemented, documented, and verified.","metadata":{"stages":4,"primary_stage":4},"labels":["primary-stage-4","stage-4","stage4","tooling","validator"],"dependencies":[{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:31Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-level-json","type":"blocks","created_at":"2026-08-18T20:54:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_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-тесты проходят.","notes":"2026-08-19: claimed as the next Stage 6 foundation slice after CorpseManager. Planned boundary: deterministic event/signal/door state model, data-driven bindings, GameView adapter, RED-GREEN integration, no level-solution changes.\n2026-08-19: completed test-first. PuzzleSystem owns pressure/toggle/timed/conductive-relay channels, immutable signal/door transitions, explicit AND-based DoorController states, platform control, diagnostics, reset and held-edge-safe save/restore. GameView now submits geometry facts and consumes events; no physics/geometry/solution changes. Final gates: 89 JVM; lint/build GREEN; Android 16 focused 1/1 and unfiltered 38/38, 0 failed/0 skipped, 433.599s; APK ZIP valid, SHA-256 adeed8b3959e2192e15917136e754e626cf06248bbba0dcd7596d55b2685e011. Evidence: docs/STAGE6_PUZZLE_SYSTEM.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T01:07:09Z","started_at":"2026-08-19T00:43:02Z","closed_at":"2026-08-19T01:07:09Z","close_reason":"Acceptance criteria выполнены: кнопки, двери и платформы связаны общей data-driven event model; integration и полная Android-матрица 38/38 зелёные","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","primary-stage-6","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-puzzle-events","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:13Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-puzzle-vslice","title":"Базовые puzzle objects vertical slice","description":"Работают pressure/toggle/timed switches, doors и moving platforms.","acceptance_criteria":"Существующие уровни используют все базовые типы.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:39Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:17Z","close_reason":"Базовые кнопки, двери и платформы реализованы.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-puzzle-vslice","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:11Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-hazards-vslice","title":"Опасности vertical slice","description":"Реализованы spikes, saw, pit, freeze и electricity.","acceptance_criteria":"Опасности присутствуют в каталоге 10 уровней и не ломают restart.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:35Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:16Z","close_reason":"Базовый набор опасностей присутствует в 10 уровнях.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-hazards-vslice","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-corpse-manager","title":"Выделить полноценный CorpseManager","description":"Убрать управление телами из монолитного GameView; добавить register/remove/transform/reset, лимит и диагностические счётчики.","acceptance_criteria":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","notes":"2026-08-19: next Stage 6 foundation slice after character state machine. Planned boundary: deep public corpse lifecycle contract, GameView extraction without level-solution changes, test-first register/remove/transform/reset/limit/diagnostics.\n2026-08-19: completed test-first. CorpseManager now owns bounded register/remove/reset/FIFO eviction, composable pinned/frozen/conductive transformations, body save/restore and immutable diagnostics. GameView remains the physics adapter; no physics, geometry or level-signature changes. Final gates: 84 JVM tests; lintDebug/assembleDebug/assembleDebugAndroidTest GREEN; Android 16 unfiltered 37/37, 0 failed/0 skipped, 427.237s; APK ZIP valid, SHA-256 982fcf312f10bf23feb3a8794b708b61002012a3706c94ebb4320fefb36fb889. Evidence: docs/STAGE6_CORPSE_MANAGER.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:43:01Z","started_at":"2026-08-19T00:19:59Z","closed_at":"2026-08-19T00:43:01Z","close_reason":"Acceptance criteria выполнены: отдельный lifecycle-контракт интегрирован, существующие решения сохранены, полная Android-матрица 37/37 зелёная","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","corpse","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-manager","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:04Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-character-states","title":"Завершить state machine персонажа","description":"Добавить явные Interact, Stunned, Turn, Land и animation-state переходы; вынести coyote/input buffer в конфиг.","acceptance_criteria":"Все переходы детерминированы, визуально различимы и имеют тесты.","notes":"2026-08-19: Stage 6 started after accepted Vertical Slice. First vertical slice: explicit deterministic character state machine (Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death), movement timings sourced from PhysicsConfig, GameView integration without physics changes, public automation state, JVM contract and full build gate.\n2026-08-19: TDD complete. Explicit Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death model, configured timings, non-color poses, GameView spawn/update/death/restore/switch/automation integration. Final exact gates: 81 JVM; lint/build; Android 16 unfiltered 36/36 in 720.578s; APK ZIP and SHA d25b7dd0a06cead0d514539773acd0a84ed92bf7c2510b9b880ded2f66508581. Evidence: docs/STAGE6_CHARACTER_STATES.md.","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:39:20Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:19:57Z","started_at":"2026-08-18T23:20:45Z","closed_at":"2026-08-19T00:19:57Z","close_reason":"All character transitions are deterministic, visually distinct, configured and proven by JVM plus exact Android 16 integration/regression gates.","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":"closed","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-19T05:58:27Z","closed_at":"2026-08-19T05:58:27Z","close_reason":"All Save \u0026 Progression children accepted: base save, versioned migration, challenges/collectibles/statistics. Exact Android gate 52/52 and legacy migrations green.","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":"closed","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-19T04:38:26Z","closed_at":"2026-08-19T04:38:26Z","close_reason":"All puzzle-object children are complete: baseline switches/doors/platforms, event-driven PuzzleSystem and shared weighted physical objects. Player, corpse and authored objects now use data-driven uncoupled contracts with full regression evidence.","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":"closed","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-19T02:28:53Z","closed_at":"2026-08-19T02:28:53Z","close_reason":"Both vertical-slice and production hazard catalogs are complete; all data-driven definitions, teaching levels and Android QA gates are green.","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.","notes":"2026-08-19: baseline controller and production state-machine children are closed; exact Android 16 36/36 confirms stable controls and routes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","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-19T00:19:58Z","closed_at":"2026-08-19T00:19:58Z","close_reason":"Character controller and complete production animation-state contract are implemented and Android-verified.","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.","notes":"2026-08-19: claimed after weighted-object slice 259fcd3 passed 108 app JVM + 2 content-tool JVM and exact Android 48/48. TDD scope: stable LevelID/WorldID progression keys with legacy numeric migration, casualty/time/collectible challenge evaluation, persistent best three-star record, data-driven optional collectible, level-select/result rendering, automation and one teaching route without weakening existing gates.\nFinal post-review gate: duplicate stable LevelID now fails catalog load before progress-key collision (targeted RED then GREEN). Clean 116 app JVM + 2 content-tool JVM; final cold host-GPU AVD exact Android 52/52 in 551.773s. Verified APK SHA-256 0b70a0a1a508425c311d7de473318e3bb83407f5f27e9dd4a8e1876b2eb25344.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T06:24:46Z","started_at":"2026-08-19T04:41:26Z","closed_at":"2026-08-19T05:58:26Z","close_reason":"Accepted stable LevelID/WorldID challenge progression: progress v3/session v5 migrations, casualty/time/collectible stars, level 18 3/3 route, 115+2 JVM and exact Android 52/52 (553.686s), APK SHA-256 9c12cfbb15e313421d58f891d9d107af14c9e8e626228fa8fd0c74e56c293d14.","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.","notes":"2026-08-19: executable matrix commit 4869649 fixed release at 69 levels: workshop 1-17 plus four 13-level production worlds (52 rows). Foundations wave 1 now authors Levels 19-22; matrix prefix is 5/52 including Level 18. TDD/device REDs fixed only in new content: inaccessible L19 cache, skipped second L20 body, late L21 saw takeoff, early-moving L22 ferry; pagination acceptance updated from 2 to 3 pages. Four route methods passed 12/12 playthroughs, restart DoD passed 1/1. Clean gate: 117/117 app JVM, 7/7 level-format, validators 22/authored5, lint/app/test APK; final cold unfiltered Android 57/57 in 1807.264s, APK SHA 81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef. Next bounded slice is Foundations 23-26; issue remains in_progress until Level 69.\n2026-08-19: Foundations wave 2 authors Levels 23-26; matrix prefix is 9/52. Remote saw delivery, frozen-body step and conductive relay pass exact 1 death/1 corpse. Level 26 Second Hand passes original-timer and expired-timer recovery routes, both 3/3 with zero deaths; recovery explicitly observes the far Door closed before rearming the shared signal. Focused suite: 5/5 methods, 15 routes, 10.768s. Clean gate: 118/118 app JVM, 7/7 level-format, validators 26/authored9, lint/app/test APK; final cold unfiltered Android 62/62 in 1063.114s, APK SHA 67510d404cfa0704500cbf36c7845e9ba9fa05e96630079ab02bc35113e946fa. Next bounded slice is Foundations 27-30; issue remains in_progress until Level 69.\n2026-08-19: Foundations wave 3 authors Levels 27-30 and closes the world at 13/13 matrix rows (authored production prefix 13/52). Level 27 proves two retained-body AND; Level 28 proves delivered/frozen/conductive exact 3-body route plus clean restart; Level 29 CHALLENGE proves exact two bodies; Level 30 FINALE combines bridge, pressure, toggle/timed ferry, PIT and Door at exact 3 bodies. Focused suite: 4/4 methods, 12 routes, 35.188s. Late-load REDs led only to deterministic test-clock hardening and an AVD-specific performance evidence boundary; physical hardware retains full 34ms frame gate. Final clean gate: 119/119 app JVM, 7/7 level-format, validators 30/authored13, lint/app/test APK; cold unfiltered Android 66/66 in 6749.094s, APK SHA d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315. Next bounded slice is Physics 31-34; issue remains in_progress until Level 69.\n2026-08-19: Physics wave 1 authors Levels 31-34; authored production prefix is 17/52. Weight Class proves mass-3 object pressure with clean restart; Stack Order adds deterministic falling object-on-object support; Launch Window proves exact one saw body plus vertical lift; Press Cycle proves exact one crushed retained body plus active/rest crusher timing. Focused suite: 4/4 methods, 12 routes; first run 10.003s, restart-proof rerun green. TDD REDs were missing PhysicalObjectStackResolver and PhysicsWaveOneScenarios. Clean gate: 122/122 app JVM, 7/7 level-format, validators 34/authored17, lint/app/test APK; cold unfiltered Android 70/70 in 611.991s, APK SHA 1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3. Next bounded slice is Physics 35-38; issue remains in_progress until Level 69.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T12:52:14Z","started_at":"2026-08-19T06:26:20Z","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 одинаково работает с персонажем, телом и объектами.","notes":"2026-08-19: claimed after corpse-state slice 19bb64e passed 102 JVM and exact Android 46/46. TDD scope: public IWeightedObject mass contract, JSON Box/Rock/Ball/IceBlock/Counterweight definitions, deterministic runtime/save/render/automation, pressure parity and one teaching level with solution bot.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T04:38:25Z","started_at":"2026-08-19T03:35:44Z","closed_at":"2026-08-19T04:38:25Z","close_reason":"Shared pressure-mass contract, five authored physical-object types, deterministic runtime/save v4/render/export, level 17 and production-input route accepted. Clean 108 app JVM + 2 format JVM; focused 8/8; exact unfiltered Android 16 matrix 48/48 in 2223.695s.","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","notes":"2026-08-19: claimed after the three Stage 6 foundation slices. Will proceed test-first per hazard with data definition, deterministic behavior, teaching content, solution graph and production-input route; crusher/fire precede the dependent crushed/burned corpse states.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T02:28:37Z","started_at":"2026-08-19T01:07:11Z","closed_at":"2026-08-19T02:28:37Z","close_reason":"Crusher, fire, projectile and cyclic explosion are data-driven, validated and shipped with teaching levels 11-14, solution graphs and 3x production-input routes; clean 99 JVM/lint/APK and unfiltered Android 43/43 green.","metadata":{"stages":6,"primary_stage":6},"labels":["hazards","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-hazards-full","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:10Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","notes":"2026-08-19: claimed after production hazards dc2ebcb passed 99 JVM and Android 43/43. Will add explicit crushed/burned/shattered and derived corpse behavior test-first, then one teaching route/solution graph per new gameplay-relevant state and full regression.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:49Z","started_at":"2026-08-19T02:31:25Z","closed_at":"2026-08-19T03:32:49Z","close_reason":"GDD corpse states complete: existing normal/pinned/frozen/conductive routes plus new burned/crushed contracts, save v3 migration, levels 15-16 and bots. Clean 102 JVM; exact Android 46/46 in 1842.248s. IsDestroyed is a lifecycle field; Shattered is not an approved GDD state.","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-hazards-full","type":"blocks","created_at":"2026-08-19T04:07:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e14","title":"EPIC-14 Analytics","description":"События прохождения, позиции смертей и crash reporting без лишних персональных данных. Раздел 47.","acceptance_criteria":"События и ошибки проверены в тестовом канале, схема версионирована.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:12Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:57Z","metadata":{"stages":"7,8,9,10","primary_stage":7},"labels":["analytics","epic","primary-stage-7","stage-10","stage-7","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e14","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:57Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e11","title":"EPIC-11 Art","description":"Самостоятельное художественное направление, модульные персонажи и анимации. Разделы 26–28.","acceptance_criteria":"Визуальный язык утверждён, оригинален и покрывает все игровые состояния.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:44Z","metadata":{"stages":"5,6","primary_stage":6},"labels":["art","epic","primary-stage-6","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e11","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e10","title":"EPIC-10 Audio","description":"Sound design, вариативность реакций и музыкальные loops. Разделы 29–30.","acceptance_criteria":"Vertical slice имеет полный audio pass без раздражающих повторов.","notes":"Stage 5 audio acceptance complete: procedural music/effects/voice variation, lifecycle safety and independent persisted controls; the EPIC child is closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:03Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:09Z","closed_at":"2026-08-18T23:03:09Z","close_reason":"Vertical Slice has a complete non-repeating audio pass.","metadata":{"stages":5,"primary_stage":5},"labels":["audio","epic","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-e10","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:52Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_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}
|
||||
|
||||
@@ -7,11 +7,20 @@ 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/STAGE6_CORPSE_STATES.md`](docs/STAGE6_CORPSE_STATES.md) — burned/crushed body contracts and acceptance evidence;
|
||||
- [`docs/STAGE6_WEIGHTED_OBJECTS.md`](docs/STAGE6_WEIGHTED_OBJECTS.md) — shared mass contract, physical objects, and level 17 evidence;
|
||||
- [`docs/STAGE6_CHALLENGES.md`](docs/STAGE6_CHALLENGES.md) — stable level IDs, three-star challenges, collectibles, and level 18 evidence;
|
||||
- [`docs/STAGE5_HUMAN_PLAYTEST.md`](docs/STAGE5_HUMAN_PLAYTEST.md) — required external acceptance protocol;
|
||||
- [`docs/STAGE_REGISTRY.md`](docs/STAGE_REGISTRY.md) — mandatory Stage 0–10 ownership for every Bead;
|
||||
- [`docs/BEADS_WORKFLOW.md`](docs/BEADS_WORKFLOW.md) — local backlog structure and daily Beads commands.
|
||||
|
||||
## Playable content
|
||||
|
||||
The APK contains ten sequential levels:
|
||||
The APK contains eighteen sequential levels:
|
||||
|
||||
1. movement and jumping;
|
||||
2. bodies covering a spike lane;
|
||||
@@ -22,9 +31,17 @@ The APK contains ten sequential levels:
|
||||
7. an electrified corpse completing a relay;
|
||||
8. a timed switch and moving platform;
|
||||
9. a two-signal door requiring weight and electricity;
|
||||
10. a mastery level combining saw, freeze, electricity, pressure plate, pit, moving lift, and final gate.
|
||||
10. a mastery level combining saw, freeze, electricity, pressure plate, pit, moving lift, and final gate;
|
||||
11. a rhythmic crusher and its safe rest phase;
|
||||
12. a fire lane with a staged jump;
|
||||
13. two state-driven projectile crossings;
|
||||
14. explosion delivery to a remote pressure door;
|
||||
15. a crushed body whose low collider becomes a required step;
|
||||
16. a burned body that stays solid but no longer powers a pressure bridge;
|
||||
17. five physical object types sharing a mass-nine pressure gate;
|
||||
18. a teaching route that combines casualty/time targets with an optional cache and persistent three-star result.
|
||||
|
||||
Death does not reset the level. A new volunteer respawns after a short delay while retained bodies stay in the simulation. The restart button deliberately clears the puzzle state. Progress, best time, and lowest volunteer count are stored locally.
|
||||
Death does not reset the level. A new volunteer respawns after a short delay while retained bodies stay in the simulation. The restart button deliberately clears the puzzle state. Progress, best time, lowest volunteer count, collected caches, and earned stars are stored by stable level ID.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -33,7 +50,14 @@ Death does not reset the level. A new volunteer respawns after a short delay whi
|
||||
- `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, and vibration on/off. Changes persist across app restarts.
|
||||
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.
|
||||
|
||||
@@ -45,7 +69,21 @@ Requirements: JDK 17 and Android SDK Platform/Build Tools 34.
|
||||
./gradlew testDebugUnitTest assembleDebug
|
||||
```
|
||||
|
||||
All ten levels have device-level acceptance bots. They drive the production
|
||||
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 :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 eighteen 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
|
||||
@@ -59,6 +97,67 @@ matrix with:
|
||||
./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.
|
||||
|
||||
Stage 6 production work has started. Its three completed foundation slices add a
|
||||
deterministic character state machine, a bounded `CorpseManager`, and an
|
||||
event-driven `PuzzleSystem` with explicit signal and door transitions. The first
|
||||
mechanics slice adds data-driven crusher, fire, projectile and explosion hazards,
|
||||
teaching levels 11–14, dynamic level pagination and production-input routes.
|
||||
Burned/crushed corpse states are taught in levels 15–16, level 17 adds five
|
||||
authored physical-object types over one shared pressure-mass contract, and level
|
||||
18 teaches the casualty/time/cache challenge contract with persistent three-star
|
||||
progress keyed by stable `levelId`.
|
||||
The first bounded Foundations authoring wave adds levels 19–22: a two-jump
|
||||
movement route, a two-body spike bridge, a retained pressure-door body and a
|
||||
toggle-controlled horizontal ferry. All four routes pass three consecutive runs,
|
||||
and restart clears the stateful bridge/ferry setups.
|
||||
The second wave adds levels 23–26: remote saw delivery, a frozen-body step, a
|
||||
conductive-body relay and a timed ferry with an explicit far-side recovery relay.
|
||||
Five route methods prove all four levels three times, including both the original
|
||||
timer and expired-timer recovery routes for level 26.
|
||||
The final Foundations wave adds levels 27–30: a two-body AND gate, a three-state
|
||||
body sequence with clean restart, an exact-two-death challenge and a finale that
|
||||
combines corpse bridge, pressure, toggle/timer ferry and Door. The full
|
||||
Foundations world now occupies its accepted 18–30 matrix range.
|
||||
The first Physics wave adds levels 31–34: object-held mass, a real three-object
|
||||
support stack, a saw-delivery lift route and a crusher/pressure cycle. Vertical
|
||||
object-on-object support is deterministic and covered independently from player
|
||||
movement; all four routes pass three consecutive production-input runs.
|
||||
Scope and final evidence are in `docs/STAGE6_CHARACTER_STATES.md`,
|
||||
`docs/STAGE6_CORPSE_MANAGER.md`, and `docs/STAGE6_PUZZLE_SYSTEM.md`; the latest
|
||||
hazard evidence is in `docs/STAGE6_PRODUCTION_HAZARDS.md`, and the burned/crushed
|
||||
contract is in `docs/STAGE6_CORPSE_STATES.md`. Weighted-object evidence is in
|
||||
`docs/STAGE6_WEIGHTED_OBJECTS.md`; challenge and migration evidence is in
|
||||
`docs/STAGE6_CHALLENGES.md`. The executable content matrix fixes the release at
|
||||
69 levels: 17 accepted workshop levels plus 52 production slots in Foundations,
|
||||
Physics, States and Mastery. Its contract and Level DoD pipeline are documented
|
||||
in `docs/STAGE6_CONTENT_MATRIX.md`; wave evidence is in
|
||||
`docs/STAGE6_FOUNDATIONS_WAVE1.md` and
|
||||
`docs/STAGE6_FOUNDATIONS_WAVE2.md`; the completed world evidence is in
|
||||
`docs/STAGE6_FOUNDATIONS_WAVE3.md`. Physics evidence starts in
|
||||
`docs/STAGE6_PHYSICS_WAVE1.md`. The latest exact gate is `122 app JVM + 7
|
||||
content-tool JVM / 70 Android` on the thirty-four-level candidate (`70/70`,
|
||||
611.991 seconds). The verified debug APK SHA-256 is
|
||||
`1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3`.
|
||||
|
||||
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
|
||||
|
||||
+8
-2
@@ -10,8 +10,8 @@ android {
|
||||
applicationId 'com.projectsacrifice.game'
|
||||
minSdk 23
|
||||
targetSdk 34
|
||||
versionCode 6
|
||||
versionName '0.5.0-stage3-controls'
|
||||
versionCode 9
|
||||
versionName '0.8.0-stage5-vslice'
|
||||
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
@@ -38,8 +38,14 @@ 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'
|
||||
dependsOn ':level-format:validateContentMatrix'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Runs before stateful playthrough suites so the release frame budget measures a clean install,
|
||||
* rather than host-emulator degradation accumulated by more than an hour of instrumentation.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class AReleasePerformanceGateTest {
|
||||
@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();
|
||||
if (isEmulator()) {
|
||||
assertRendererCostWithinBudget(metrics, budget);
|
||||
} else {
|
||||
// Only physical hardware can own the end-to-end frame-interval contract. An AVD
|
||||
// shares its scheduler with the host, but still owns update/draw/complexity cost.
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isEmulator() {
|
||||
return Build.FINGERPRINT.startsWith("generic")
|
||||
|| Build.MODEL.contains("sdk_gphone")
|
||||
|| Build.HARDWARE.contains("ranchu");
|
||||
}
|
||||
|
||||
private static void assertRendererCostWithinBudget(PerformanceMonitor.Snapshot metrics,
|
||||
PerformanceBudget budget) {
|
||||
String diagnostic = metrics.failureSummary(budget);
|
||||
assertTrue("AVD must still publish frame intervals; " + diagnostic,
|
||||
metrics.p95FrameIntervalMs() > 0f);
|
||||
assertTrue(diagnostic, metrics.p95UpdateMs() <= budget.maximumP95UpdateMs());
|
||||
assertTrue(diagnostic, metrics.p95DrawMs() <= budget.maximumP95DrawMs());
|
||||
assertTrue(diagnostic, metrics.maximumBodies() <= budget.maximumBodies());
|
||||
assertTrue(diagnostic,
|
||||
metrics.maximumCollisionChecks() <= budget.maximumCollisionChecks());
|
||||
assertTrue(diagnostic,
|
||||
metrics.maximumDrawPrimitives() <= budget.maximumDrawPrimitives());
|
||||
assertTrue(diagnostic,
|
||||
metrics.maximumUsedMemoryBytes() <= budget.maximumUsedMemoryBytes());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class ChallengeCollectibleIntegrationTest {
|
||||
@Test
|
||||
public void collectedCacheSurvivesRecreationAndRestartResetsAttemptState() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(18);
|
||||
assertTrue(view.automationSnapshot().collectibleVisible());
|
||||
view.setAutomationInput(false, true, false);
|
||||
for (int decision = 0; decision < 400
|
||||
&& view.automationSnapshot().playerX() < 650f; decision++) {
|
||||
view.advanceDeterministicAutomation(16L);
|
||||
}
|
||||
assertTrue("approach x=" + view.automationSnapshot().playerX(),
|
||||
view.automationSnapshot().playerX() >= 650f);
|
||||
view.setAutomationInput(false, true, true);
|
||||
for (int decision = 0; decision < 180
|
||||
&& !view.automationSnapshot().collectibleFound(); decision++) {
|
||||
view.advanceDeterministicAutomation(16L);
|
||||
}
|
||||
view.setAutomationInput(false, false, false);
|
||||
assertTrue(view.automationSnapshot().collectibleFound());
|
||||
assertFalse(view.automationSnapshot().collectibleVisible());
|
||||
assertTrue(view.sessionSnapshotForAutomation().collectibleFound());
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
assertTrue(view.automationSnapshot().visiblyPaused());
|
||||
assertTrue(view.automationSnapshot().collectibleFound());
|
||||
assertFalse(view.automationSnapshot().collectibleVisible());
|
||||
view.startLevelForAutomation(18);
|
||||
assertFalse(view.automationSnapshot().collectibleFound());
|
||||
assertTrue(view.automationSnapshot().collectibleVisible());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class ChallengePlaythroughTest {
|
||||
private Context context;
|
||||
|
||||
@Before
|
||||
public void resetProgress() {
|
||||
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
context.getSharedPreferences("project_sacrifice_progress_v1", Context.MODE_PRIVATE)
|
||||
.edit().clear().commit();
|
||||
}
|
||||
|
||||
@After
|
||||
public void cleanProgress() {
|
||||
context.getSharedPreferences("project_sacrifice_progress_v1", Context.MODE_PRIVATE)
|
||||
.edit().clear().commit();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void intendedRouteEarnsAndPersistsAllThreeStarsThreeTimes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Driver driver = Driver.from(activity);
|
||||
for (int run = 1; run <= 3; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
driver, ChallengeScenarios.levelEighteen(), 20_000L);
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals(0, result.deaths());
|
||||
assertTrue(driver.snapshot().collectibleFound());
|
||||
assertEquals(3, driver.result().challengeResult().stars());
|
||||
assertEquals(3, driver.result().bestStars());
|
||||
}
|
||||
assertEquals(3, new ProgressStore(context).earnedStars(LevelCatalog.get(17)));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Driver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
private Driver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
static Driver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] target = new GameView[1];
|
||||
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
|
||||
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(oneBasedLevelNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean left, boolean right, boolean jump) {
|
||||
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
|
||||
return value[0];
|
||||
}
|
||||
|
||||
CompletionResult result() {
|
||||
CompletionResult[] value = new CompletionResult[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = view.completionResultForAutomation());
|
||||
return value[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
instrumentation.runOnMainSync(() ->
|
||||
view.advanceDeterministicAutomation(Math.max(16L, millis)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class ChallengeProgressPersistenceTest {
|
||||
private Context context;
|
||||
private SharedPreferences raw;
|
||||
|
||||
@Before
|
||||
public void clearProgress() {
|
||||
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
raw = context.getSharedPreferences("project_sacrifice_progress_v1", Context.MODE_PRIVATE);
|
||||
raw.edit().clear().commit();
|
||||
}
|
||||
|
||||
@After
|
||||
public void cleanProgress() {
|
||||
raw.edit().clear().commit();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void numericV2ProgressMigratesToStableLevelIdWithoutLoss() {
|
||||
LevelDefinition level = LevelCatalog.get(0);
|
||||
raw.edit().putInt("save_version", 2)
|
||||
.putBoolean("level_1_completed", true)
|
||||
.putInt("level_1_deaths", 2)
|
||||
.putLong("level_1_time", 41_000L)
|
||||
.commit();
|
||||
|
||||
ProgressStore progress = new ProgressStore(context);
|
||||
|
||||
assertEquals(3, progress.saveVersion());
|
||||
assertTrue(progress.isCompleted(level.levelId));
|
||||
assertEquals(2, progress.bestDeaths(level.levelId));
|
||||
assertEquals(41_000L, progress.bestTimeMs(level.levelId));
|
||||
assertTrue(raw.contains("level_id_" + level.levelId + "_completed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void independentRunsAccumulateBestThreeStarsByLevelId() {
|
||||
LevelDefinition base = LevelCatalog.get(0);
|
||||
LevelDefinition level = new LevelDefinition(
|
||||
base.formatVersion, base.physicsVersion, "foundations.star-lab", "foundations",
|
||||
base.number, base.title, base.hint, base.titleRu, base.hintRu,
|
||||
base.worldWidth, base.worldHeight, base.spawnX, base.spawnY, base.goal,
|
||||
base.targetDeaths, base.targetSeconds,
|
||||
new LevelDefinition.Box(500f, 700f, 50f, 50f), base.objects,
|
||||
base.platforms, base.hazards, base.switches, base.doors);
|
||||
ProgressStore progress = new ProgressStore(context);
|
||||
|
||||
progress.recordCompletion(level, base.targetDeaths,
|
||||
base.targetSeconds * 1_000L + 5_000L, false);
|
||||
assertEquals(1, progress.earnedStars(level));
|
||||
|
||||
progress.recordCompletion(level, base.targetDeaths + 2,
|
||||
base.targetSeconds * 1_000L - 1_000L, true);
|
||||
|
||||
assertEquals(3, progress.earnedStars(level));
|
||||
assertTrue(progress.collectibleFound(level.levelId));
|
||||
assertEquals(base.targetDeaths, progress.bestDeaths(level.levelId));
|
||||
assertEquals(base.targetSeconds * 1_000L - 1_000L,
|
||||
progress.bestTimeMs(level.levelId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Production-input route for the first three-goal challenge level. */
|
||||
final class ChallengeScenarios {
|
||||
private ChallengeScenarios() {}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelEighteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(18, "Level 18 - threefold proof")
|
||||
.step("Approach the optional cache", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(650f)))
|
||||
.step("Jump through the optional cache", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.collectibleFound()))
|
||||
.step("Land beyond the cache", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(1_050f)))
|
||||
.step("Reach the relic within both efficiency targets", 5_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
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 CharacterStateIntegrationTest {
|
||||
@Test
|
||||
public void gameViewPublishesMovementAndCommandStates() 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());
|
||||
instrumentation.runOnMainSync(() -> view[0].startLevelForAutomation(1));
|
||||
|
||||
awaitState(instrumentation, view[0], CharacterStateMachine.State.IDLE, 3_000L);
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view[0].setAutomationInput(false, true, false));
|
||||
awaitState(instrumentation, view[0], CharacterStateMachine.State.RUN, 3_000L);
|
||||
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view[0].setAutomationInput(true, false, false));
|
||||
awaitState(instrumentation, view[0], CharacterStateMachine.State.TURN, 3_000L);
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view[0].setAutomationInput(false, true, true));
|
||||
awaitState(instrumentation, view[0], CharacterStateMachine.State.JUMP, 3_000L);
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view[0].setAutomationInput(false, true, false));
|
||||
awaitState(instrumentation, view[0], CharacterStateMachine.State.FALL, 3_000L);
|
||||
awaitState(instrumentation, view[0], CharacterStateMachine.State.LAND, 3_000L);
|
||||
|
||||
instrumentation.runOnMainSync(view[0]::triggerInteractionForAutomation);
|
||||
assertEquals(CharacterStateMachine.State.INTERACT,
|
||||
state(instrumentation, view[0]));
|
||||
instrumentation.runOnMainSync(view[0]::triggerStunForAutomation);
|
||||
assertEquals(CharacterStateMachine.State.STUNNED,
|
||||
state(instrumentation, view[0]));
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitState(Instrumentation instrumentation, GameView view,
|
||||
CharacterStateMachine.State expected,
|
||||
long timeoutMillis) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
CharacterStateMachine.State current = state(instrumentation, view);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
current = state(instrumentation, view);
|
||||
if (current == expected) return;
|
||||
Thread.sleep(24L);
|
||||
}
|
||||
throw new AssertionError("Expected " + expected + " but was " + current);
|
||||
}
|
||||
|
||||
private static CharacterStateMachine.State state(
|
||||
Instrumentation instrumentation, GameView view) {
|
||||
CharacterStateMachine.State[] result = new CharacterStateMachine.State[1];
|
||||
instrumentation.runOnMainSync(
|
||||
() -> result[0] = view.characterStateForAutomation());
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -22,13 +22,13 @@ public final class ControlSettingsPersistenceTest {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> current.gameViewForAutomation()
|
||||
.resetControlSettingsForAutomation());
|
||||
|
||||
tap(activity, 1_700f, 115f);
|
||||
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);
|
||||
tap(activity, 1_250f, 685f);
|
||||
|
||||
activity.recreate();
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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 CorpseManagerIntegrationTest {
|
||||
@Test
|
||||
public void deathRegistersAndRestartResetsTheOwnedBodyLifecycle() 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());
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view[0].startLevelForAutomation(10);
|
||||
view[0].setAutomationInput(false, true, false);
|
||||
});
|
||||
|
||||
GameView.AutomationSnapshot died = awaitBody(instrumentation, view[0], 8_000L);
|
||||
CorpseManager.Diagnostics before = diagnostics(instrumentation, view[0]);
|
||||
assertEquals(1, died.corpseCount());
|
||||
assertEquals(1L, before.registered());
|
||||
assertEquals(0L, before.evicted());
|
||||
|
||||
instrumentation.runOnMainSync(view[0]::restartLevelForAutomation);
|
||||
GameView.AutomationSnapshot restarted = snapshot(instrumentation, view[0]);
|
||||
CorpseManager.Diagnostics after = diagnostics(instrumentation, view[0]);
|
||||
|
||||
assertEquals(0, restarted.corpseCount());
|
||||
assertEquals(before.resets() + 1L, after.resets());
|
||||
assertEquals(before.clearedByReset() + 1L, after.clearedByReset());
|
||||
assertTrue(restarted.playerAlive());
|
||||
}
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot awaitBody(
|
||||
Instrumentation instrumentation, GameView view, long timeoutMillis)
|
||||
throws Exception {
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
GameView.AutomationSnapshot current = snapshot(instrumentation, view);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
current = snapshot(instrumentation, view);
|
||||
if (current.corpseCount() == 1) return current;
|
||||
Thread.sleep(24L);
|
||||
}
|
||||
throw new AssertionError("Body was not registered; deaths=" + current.deaths()
|
||||
+ ", corpses=" + current.corpseCount());
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot snapshot(
|
||||
Instrumentation instrumentation, GameView view) {
|
||||
GameView.AutomationSnapshot[] result = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> result[0] = view.automationSnapshot());
|
||||
return result[0];
|
||||
}
|
||||
|
||||
private static CorpseManager.Diagnostics diagnostics(
|
||||
Instrumentation instrumentation, GameView view) {
|
||||
CorpseManager.Diagnostics[] result = new CorpseManager.Diagnostics[1];
|
||||
instrumentation.runOnMainSync(
|
||||
() -> result[0] = view.corpseDiagnosticsForAutomation());
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class CorpseStateIntegrationTest {
|
||||
@Test
|
||||
public void crusherAndFireDeathsPublishDistinctPuzzleBodyStates() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.enableDeterministicAutomationClock();
|
||||
|
||||
view.startLevelForAutomation(11);
|
||||
view.setAutomationInput(false, true, false);
|
||||
view.advanceDeterministicAutomation(2_000L);
|
||||
GameView.AutomationSnapshot crushed = view.automationSnapshot();
|
||||
assertTrue(crushed.firstCorpseCrushed());
|
||||
assertFalse(crushed.firstCorpseBurned());
|
||||
assertTrue(crushed.firstCorpseActivatesPressure());
|
||||
assertTrue(crushed.firstCorpseWidth() > crushed.firstCorpseHeight());
|
||||
|
||||
view.startLevelForAutomation(12);
|
||||
view.setAutomationInput(false, true, false);
|
||||
view.advanceDeterministicAutomation(2_000L);
|
||||
GameView.AutomationSnapshot burned = view.automationSnapshot();
|
||||
assertTrue(burned.firstCorpseBurned());
|
||||
assertFalse(burned.firstCorpseCrushed());
|
||||
assertFalse(burned.firstCorpseActivatesPressure());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class CorpseStatePlaythroughTest {
|
||||
private static final int CONSECUTIVE_RUNS = 3;
|
||||
|
||||
@Test public void crushedBodyIsARequiredLowStepThreeTimes() {
|
||||
prove(CorpseStateScenarios.levelFifteen(), 45_000L);
|
||||
}
|
||||
|
||||
@Test public void burnedBodyLeavesThePressureBridgeDormantThreeTimes() {
|
||||
prove(CorpseStateScenarios.levelSixteen(), 45_000L);
|
||||
}
|
||||
|
||||
private static void prove(LevelPlaytestBot.Scenario scenario, long budget) {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
AndroidDriver driver = AndroidDriver.from(activity);
|
||||
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, budget);
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run + "; route uses exactly one transformed body",
|
||||
1, result.deaths());
|
||||
assertEquals("run=" + run + "; transformed body must remain",
|
||||
1, result.corpseCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView gameView;
|
||||
|
||||
private AndroidDriver(Instrumentation instrumentation, GameView gameView) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.gameView = gameView;
|
||||
}
|
||||
|
||||
static AndroidDriver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> snapshot[0] = gameView.automationSnapshot());
|
||||
return snapshot[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
Thread.sleep(Math.max(32L, millis));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Corpse-state bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Intended routes whose solution depends on the transformed corpse state. */
|
||||
final class CorpseStateScenarios {
|
||||
private CorpseStateScenarios() {}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelFifteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(15, "Level 15 - crushed low step")
|
||||
.step("Create one crushed body", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1
|
||||
&& snapshot.firstCorpseCrushed()
|
||||
&& snapshot.firstCorpseWidth() > snapshot.firstCorpseHeight())
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the press step", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Observe a fresh press cycle", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::firstHazardActive)
|
||||
.step("Wait for the full rest window", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.firstHazardActive())
|
||||
.step("Land on the crushed low step", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 690f
|
||||
&& snapshot.playerY() <= 757f))
|
||||
.step("Jump from the step to the ledge", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 850f
|
||||
&& snapshot.playerY() <= 621f))
|
||||
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelSixteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(16, "Level 16 - burned pressure immunity")
|
||||
.step("Create one burned body on the plate", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1
|
||||
&& snapshot.firstCorpseBurned()
|
||||
&& !snapshot.firstCorpseActivatesPressure())
|
||||
.step("Prove the burned body leaves the bridge dormant", 2_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.firstMovingPlatformPowered()
|
||||
&& snapshot.firstMovingPlatformY() >= 800f)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the flame before touching its plate", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(535f)))
|
||||
.step("Take off over flame and plate", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 700f))
|
||||
.step("Clear the burned plate", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 850f))
|
||||
.step("Approach the dormant bridge", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 900f
|
||||
&& !snapshot.firstMovingPlatformPowered())
|
||||
.step("Board the dormant bridge", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
GameView.AutomationSnapshot::playerOnMovingPlatform))
|
||||
.step("Cross to the far floor", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_450f))
|
||||
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
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 FoundationsAdvancedPlaythroughTest {
|
||||
@Test public void remoteVolunteerUsesOneDeliveredBodyThreeTimes() {
|
||||
runThree(AdvancedFoundationsScenarios.levelTwentyThree(), 1, 1);
|
||||
}
|
||||
|
||||
@Test public void frozenStepUsesOneFrozenBodyThreeTimes() {
|
||||
runThree(AdvancedFoundationsScenarios.levelTwentyFour(), 1, 1);
|
||||
}
|
||||
|
||||
@Test public void livingWireUsesOneConductiveBodyThreeTimes() {
|
||||
runThree(AdvancedFoundationsScenarios.levelTwentyFive(), 1, 1);
|
||||
}
|
||||
|
||||
@Test public void secondHandCompletesOnTheOriginalTimerThreeTimes() {
|
||||
runThree(AdvancedFoundationsScenarios.levelTwentySixIntended(), 0, 0);
|
||||
}
|
||||
|
||||
@Test public void secondHandRecoversAfterTheFarGateClosesThreeTimes() {
|
||||
runThree(AdvancedFoundationsScenarios.levelTwentySixRecovery(), 0, 0);
|
||||
}
|
||||
|
||||
private static void runThree(LevelPlaytestBot.Scenario scenario,
|
||||
int expectedDeaths, int expectedCorpses) {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Driver driver = Driver.from(activity);
|
||||
for (int run = 1; run <= 3; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 45_000L);
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run, expectedDeaths, result.deaths());
|
||||
assertEquals("run=" + run, expectedCorpses, result.corpseCount());
|
||||
assertTrue("run=" + run + "; collectible", driver.snapshot().collectibleFound());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Driver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
private Driver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
static Driver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] target = new GameView[1];
|
||||
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
|
||||
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(oneBasedLevelNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean left, boolean right, boolean jump) {
|
||||
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
|
||||
return value[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
instrumentation.runOnMainSync(() ->
|
||||
view.advanceDeterministicAutomation(Math.max(16L, millis)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
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 FoundationsFinalePlaythroughTest {
|
||||
@Test public void twoKeysRequiresBothRetainedBodiesThreeTimes() {
|
||||
runThree(FoundationsFinaleScenarios.levelTwentySeven(), 2, 2);
|
||||
}
|
||||
|
||||
@Test public void cleanRestartBuildsThreeDistinctBodiesThreeTimesAndResets() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Driver driver = Driver.from(activity);
|
||||
for (int run = 1; run <= 3; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
driver, FoundationsFinaleScenarios.levelTwentyEight(), 55_000L);
|
||||
assertAccepted(run, result, driver.snapshot(), 3, 3);
|
||||
}
|
||||
driver.restart();
|
||||
GameView.AutomationSnapshot restarted = driver.snapshot();
|
||||
assertEquals(28, restarted.levelNumber());
|
||||
assertTrue(restarted.playerAlive());
|
||||
assertEquals(0, restarted.deaths());
|
||||
assertEquals(0, restarted.corpseCount());
|
||||
assertFalse(restarted.collectibleFound());
|
||||
assertFalse(restarted.inputActive());
|
||||
assertTrue(restarted.elapsedMilliseconds() < 100L);
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void minimumCostUsesExactlyTwoBodiesThreeTimes() {
|
||||
runThree(FoundationsFinaleScenarios.levelTwentyNine(), 2, 2);
|
||||
}
|
||||
|
||||
@Test public void foundationRelayCompletesTheWorldWithExactlyThreeBodiesThreeTimes() {
|
||||
runThree(FoundationsFinaleScenarios.levelThirty(), 3, 3);
|
||||
}
|
||||
|
||||
private static void runThree(LevelPlaytestBot.Scenario scenario,
|
||||
int expectedDeaths, int expectedCorpses) {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Driver driver = Driver.from(activity);
|
||||
for (int run = 1; run <= 3; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 55_000L);
|
||||
assertAccepted(run, result, driver.snapshot(), expectedDeaths, expectedCorpses);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertAccepted(int run, LevelPlaytestBot.Result result,
|
||||
GameView.AutomationSnapshot snapshot,
|
||||
int expectedDeaths, int expectedCorpses) {
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run, expectedDeaths, result.deaths());
|
||||
assertEquals("run=" + run, expectedCorpses, result.corpseCount());
|
||||
assertTrue("run=" + run + "; collectible", snapshot.collectibleFound());
|
||||
}
|
||||
|
||||
private static final class Driver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
private Driver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
static Driver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] target = new GameView[1];
|
||||
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
|
||||
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(oneBasedLevelNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean left, boolean right, boolean jump) {
|
||||
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
|
||||
return value[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
instrumentation.runOnMainSync(() ->
|
||||
view.advanceDeterministicAutomation(Math.max(16L, millis)));
|
||||
}
|
||||
|
||||
void restart() {
|
||||
instrumentation.runOnMainSync(view::restartLevelForAutomation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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 FoundationsPlaythroughTest {
|
||||
@Test public void firstReturnCompletesDeathlessThreeTimes() {
|
||||
runThree(FoundationsScenarios.levelNineteen(), 0, 0);
|
||||
}
|
||||
|
||||
@Test public void coveredPathUsesExactlyTwoBodiesThreeTimes() {
|
||||
runThree(FoundationsScenarios.levelTwenty(), 2, 2);
|
||||
}
|
||||
|
||||
@Test public void heldOpenUsesExactlyOnePlateBodyThreeTimes() {
|
||||
runThree(FoundationsScenarios.levelTwentyOne(), 1, 1);
|
||||
}
|
||||
|
||||
@Test public void clockworkStepCompletesDeathlessThreeTimes() {
|
||||
runThree(FoundationsScenarios.levelTwentyTwo(), 0, 0);
|
||||
}
|
||||
|
||||
private static void runThree(LevelPlaytestBot.Scenario scenario,
|
||||
int expectedDeaths, int expectedCorpses) {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Driver driver = Driver.from(activity);
|
||||
for (int run = 1; run <= 3; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 30_000L);
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run, expectedDeaths, result.deaths());
|
||||
assertEquals("run=" + run, expectedCorpses, result.corpseCount());
|
||||
assertTrue("run=" + run + "; collectible", driver.snapshot().collectibleFound());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Driver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
private Driver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
static Driver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] target = new GameView[1];
|
||||
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
|
||||
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(oneBasedLevelNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean left, boolean right, boolean jump) {
|
||||
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
|
||||
return value[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
instrumentation.runOnMainSync(() ->
|
||||
view.advanceDeterministicAutomation(Math.max(16L, millis)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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 FoundationsRestartTest {
|
||||
@Test
|
||||
public void restartClearsCoveredPathBodiesAndClockworkFerryState() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
GameView[] target = new GameView[1];
|
||||
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
|
||||
GameView view = target[0];
|
||||
|
||||
start(instrumentation, view, 20);
|
||||
input(instrumentation, view, false, true, false);
|
||||
GameView.AutomationSnapshot bridge = advanceUntil(instrumentation, view,
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1);
|
||||
assertEquals(1, bridge.deaths());
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view.setAutomationInput(false, true, true);
|
||||
view.restartLevelForAutomation();
|
||||
});
|
||||
assertCleanRestart(snapshot(instrumentation, view), 20);
|
||||
|
||||
start(instrumentation, view, 22);
|
||||
input(instrumentation, view, false, true, false);
|
||||
GameView.AutomationSnapshot ferry = advanceUntil(instrumentation, view,
|
||||
GameView.AutomationSnapshot::firstMovingPlatformPowered);
|
||||
assertTrue(ferry.firstMovingPlatformPowered());
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view.setAutomationInput(false, true, true);
|
||||
view.restartLevelForAutomation();
|
||||
});
|
||||
GameView.AutomationSnapshot restartedFerry = snapshot(instrumentation, view);
|
||||
assertCleanRestart(restartedFerry, 22);
|
||||
assertFalse(restartedFerry.firstMovingPlatformPowered());
|
||||
assertEquals(940f, restartedFerry.firstMovingPlatformX(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertCleanRestart(GameView.AutomationSnapshot snapshot, int level) {
|
||||
assertEquals(level, snapshot.levelNumber());
|
||||
assertTrue(snapshot.playerAlive());
|
||||
assertEquals(0, snapshot.deaths());
|
||||
assertEquals(0, snapshot.corpseCount());
|
||||
assertFalse(snapshot.collectibleFound());
|
||||
assertFalse(snapshot.inputActive());
|
||||
assertTrue(snapshot.elapsedMilliseconds() < 100L);
|
||||
assertTrue(snapshot.playerX() >= 125f && snapshot.playerX() <= 135f);
|
||||
}
|
||||
|
||||
private static void start(Instrumentation instrumentation, GameView view, int level) {
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(level);
|
||||
});
|
||||
}
|
||||
|
||||
private static void input(Instrumentation instrumentation, GameView view,
|
||||
boolean left, boolean right, boolean jump) {
|
||||
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot advanceUntil(
|
||||
Instrumentation instrumentation, GameView view,
|
||||
LevelPlaytestBot.SnapshotCondition condition) {
|
||||
GameView.AutomationSnapshot current = snapshot(instrumentation, view);
|
||||
for (int tick = 0; tick < 100; tick++) {
|
||||
if (condition.reached(current)) return current;
|
||||
instrumentation.runOnMainSync(() -> view.advanceDeterministicAutomation(96L));
|
||||
current = snapshot(instrumentation, view);
|
||||
}
|
||||
throw new AssertionError("Foundations 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,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;
|
||||
|
||||
@@ -23,6 +23,7 @@ public final class GameRestartTest {
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view[0].enableDeterministicAutomationClock();
|
||||
view[0].startLevelForAutomation(10);
|
||||
view[0].setAutomationInput(false, true, false);
|
||||
});
|
||||
@@ -58,13 +59,13 @@ public final class GameRestartTest {
|
||||
GameView view,
|
||||
long timeoutMillis,
|
||||
LevelPlaytestBot.SnapshotCondition condition
|
||||
) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
) {
|
||||
GameView.AutomationSnapshot current = snapshot(instrumentation, view);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
long startedAt = current.elapsedMilliseconds();
|
||||
while (current.elapsedMilliseconds() - startedAt < timeoutMillis) {
|
||||
current = snapshot(instrumentation, view);
|
||||
if (condition.reached(current)) return current;
|
||||
Thread.sleep(32L);
|
||||
instrumentation.runOnMainSync(() -> view.advanceDeterministicAutomation(96L));
|
||||
}
|
||||
throw new AssertionError("Restart setup timed out; level=" + current.levelNumber()
|
||||
+ ", x=" + current.playerX() + ", deaths=" + current.deaths()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -218,8 +218,10 @@ public final class LateLevelPlaythroughTest {
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
gameView.enableDeterministicAutomationClock();
|
||||
gameView.startLevelForAutomation(oneBasedLevelNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -238,19 +240,16 @@ public final class LateLevelPlaythroughTest {
|
||||
|
||||
@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);
|
||||
}
|
||||
boolean inFinalTraversalWindow = throttleFinalTraversalOnly
|
||||
&& lastSnapshot != null
|
||||
&& lastSnapshot.levelNumber() == 10
|
||||
&& lastSnapshot.deaths() == 3
|
||||
&& lastSnapshot.playerX() >= 1_200f
|
||||
&& lastSnapshot.playerX() <= 2_200f;
|
||||
long decisionMillis = inFinalTraversalWindow
|
||||
? Math.max(millis, minimumDecisionMillis) : millis;
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.advanceDeterministicAutomation(decisionMillis));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,17 @@ final class LateLevelScenarios {
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 640f)
|
||||
.step("Stand on the frozen block", 2_500L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerX() >= 626f
|
||||
snapshot -> snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 626f
|
||||
&& snapshot.playerY() >= 668f
|
||||
&& snapshot.playerY() <= 690f)
|
||||
.step("Jump from the block to the first ledge", 2_500L,
|
||||
.step("Land on the first ledge from the frozen block", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 750f
|
||||
&& snapshot.playerY() <= 505f)
|
||||
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,
|
||||
@@ -117,18 +121,29 @@ final class LateLevelScenarios {
|
||||
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(),
|
||||
snapshot -> snapshot.playerX() >= 350f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
|
||||
.step("Take off before the launch saw", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 580f))
|
||||
.step("Clear the launch saw", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 680f))
|
||||
.step("Approach the delivered lift body", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
@@ -143,13 +158,19 @@ final class LateLevelScenarios {
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Return to the launch saw", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 350f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
|
||||
.step("Take off before the saw again", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 580f))
|
||||
.step("Clear the saw again", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 680f))
|
||||
.step("Approach the lift body again", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
@@ -190,13 +211,19 @@ final class LateLevelScenarios {
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the saw for the final crossing", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 350f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
|
||||
.step("Take off for the final saw crossing", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 580f))
|
||||
.step("Clear the saw for the final crossing", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 680f))
|
||||
.step("Approach the retained lift body", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
@@ -251,7 +278,8 @@ final class LateLevelScenarios {
|
||||
&& (snapshot.playerOnMovingPlatform()
|
||||
|| (snapshot.playerY() >= 770f
|
||||
&& !Float.isNaN(snapshot.firstMovingPlatformY())
|
||||
&& snapshot.firstMovingPlatformY() >= 780f)))
|
||||
&& snapshot.firstMovingPlatformY() >= 750f
|
||||
&& snapshot.firstMovingPlatformMovingDown())))
|
||||
.step("Take off toward the moving lift", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
@@ -259,7 +287,8 @@ final class LateLevelScenarios {
|
||||
|| snapshot.playerY() <= 670f))
|
||||
.step("Board the moving lift", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::playerOnMovingPlatform)
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
GameView.AutomationSnapshot::playerOnMovingPlatform))
|
||||
.step("Brake toward the lift center", 1_000L,
|
||||
LevelPlaytestBot.Input.left(),
|
||||
snapshot -> snapshot.playerOnMovingPlatform()
|
||||
|
||||
@@ -26,6 +26,7 @@ public final class LevelEightPlaythroughTest {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +40,7 @@ public final class LevelEightPlaythroughTest {
|
||||
.completeIntendedRoute(driver, 15_000L);
|
||||
assertTrue("Intended run " + run + ": " + result.diagnostic(), result.completed());
|
||||
assertEquals("The intended route is deathless", 0, result.deaths());
|
||||
assertTrace(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +53,16 @@ public final class LevelEightPlaythroughTest {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,163 +3,252 @@ 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) {
|
||||
Result(boolean completed, int deaths, String diagnostic, BotCommandTrace trace) {
|
||||
this.completed = completed;
|
||||
this.deaths = deaths;
|
||||
this.diagnostic = diagnostic;
|
||||
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) {
|
||||
long deadline = driver.currentTimeMillis() + timeoutMillis;
|
||||
driver = new BotTraceDriver(driver, "Level 8 recovery route");
|
||||
driver.startLevel(8);
|
||||
GameView.AutomationSnapshot snapshot = crossPit(driver, deadline);
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Cross the pit before the clock expires", snapshot);
|
||||
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, deadline, "Wait for the far-side trap",
|
||||
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, deadline, driver)) {
|
||||
return failure("Wait for the far-side trap", snapshot);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Wait for the far-side trap", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = await(driver, deadline, "Return beneath the recovery relay",
|
||||
LevelPlaytestBot.Input.left(),
|
||||
current -> current.playerX() <= 1_680f && current.playerY() >= 779f);
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Return beneath the recovery relay", snapshot);
|
||||
snapshot = positionBeneathRecoveryRelay(driver, budget);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Return beneath the recovery relay", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = pulseLeftJumpThenAwait(driver, deadline, "Jump into the recovery relay",
|
||||
snapshot = pulseRightJumpThenAwait(driver, budget, "Jump into the recovery relay",
|
||||
current -> current.allDoorsOpen());
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Jump into the recovery relay", snapshot);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Jump into the recovery relay", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = await(driver, deadline, "Land after rearming the clock",
|
||||
LevelPlaytestBot.Input.none(), current -> current.playerY() >= 779f);
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Land after rearming the clock", snapshot);
|
||||
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, deadline, snapshot, "Finish after recovery");
|
||||
return finishFromFarSide(driver, budget, snapshot, "Finish after recovery");
|
||||
}
|
||||
|
||||
Result completeIntendedRoute(LevelPlaytestBot.Driver driver, long timeoutMillis) {
|
||||
long deadline = driver.currentTimeMillis() + timeoutMillis;
|
||||
driver = new BotTraceDriver(driver, "Level 8 intended route");
|
||||
driver.startLevel(8);
|
||||
GameView.AutomationSnapshot snapshot = crossPit(driver, deadline);
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Cross the pit before the clock expires", snapshot);
|
||||
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, deadline, snapshot, "Finish on the original timer");
|
||||
return finishFromFarSide(driver, budget, snapshot, "Finish on the original timer");
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver, long deadline) {
|
||||
GameView.AutomationSnapshot snapshot = await(driver, deadline, "Trigger the left 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, deadline, driver)) return snapshot;
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
long timerStartedAt = snapshot.elapsedMilliseconds();
|
||||
|
||||
snapshot = await(driver, deadline, "Approach the pit",
|
||||
snapshot = await(driver, budget, "Approach the pit",
|
||||
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
snapshot = await(driver, deadline, "Wait for the moving platform",
|
||||
snapshot = await(driver, budget, "Wait for the outbound moving platform",
|
||||
LevelPlaytestBot.Input.none(),
|
||||
current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L
|
||||
&& current.firstMovingPlatformX() <= 1_050f);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
&& 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, deadline, "Land on the moving platform",
|
||||
snapshot = await(driver, budget, "Land on the moving platform",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::playerOnMovingPlatform);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
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, deadline, "Jump from the moving platform to the far floor",
|
||||
snapshot = await(driver, budget, "Jump from the moving platform to the far floor",
|
||||
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
snapshot = awaitFarFloorLandingOnOriginalTimer(driver, deadline);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
return await(driver, deadline, "Move under the relay after landing",
|
||||
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, long deadline
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
|
||||
) {
|
||||
GameView.AutomationSnapshot snapshot = await(driver, deadline,
|
||||
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, deadline, driver)) return snapshot;
|
||||
return await(driver, deadline, "Stabilize on the far floor",
|
||||
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, long deadline,
|
||||
private Result finishFromFarSide(LevelPlaytestBot.Driver driver,
|
||||
PlaytestSimulationBudget budget,
|
||||
GameView.AutomationSnapshot snapshot, String step) {
|
||||
snapshot = await(driver, deadline, "Reach the open-door jump",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_730f && current.allDoorsOpen());
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Reach the open-door jump", snapshot);
|
||||
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, deadline, step, LevelPlaytestBot.Input.right(),
|
||||
snapshot = await(driver, budget, step, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed);
|
||||
driver.setInput(false, false, false);
|
||||
if (!snapshot.completed()) return failure(step, snapshot);
|
||||
return new Result(true, snapshot.deaths(), step + " completed; " + state(snapshot));
|
||||
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 pulseLeftJumpThenAwait(
|
||||
LevelPlaytestBot.Driver driver, long deadline, String step,
|
||||
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
|
||||
) {
|
||||
driver.setInput(true, false, true);
|
||||
traceStep(driver, step);
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(true, false, false);
|
||||
return await(driver, deadline, step, LevelPlaytestBot.Input.left(), completion);
|
||||
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, long deadline, String step,
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step,
|
||||
LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion
|
||||
) {
|
||||
lastStep = step;
|
||||
traceStep(driver, step);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (driver.currentTimeMillis() < deadline && snapshot.playerAlive()
|
||||
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 (driver.currentTimeMillis() >= deadline || !snapshot.playerAlive()) {
|
||||
if (budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
|| !snapshot.playerAlive()) {
|
||||
driver.setInput(false, false, false);
|
||||
}
|
||||
return snapshot;
|
||||
@@ -177,14 +266,34 @@ final class LevelEightScenario {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean failed(GameView.AutomationSnapshot snapshot, long deadline,
|
||||
private static boolean failed(GameView.AutomationSnapshot snapshot,
|
||||
PlaytestSimulationBudget budget,
|
||||
LevelPlaytestBot.Driver driver) {
|
||||
return !snapshot.completed()
|
||||
&& (!snapshot.playerAlive() || driver.currentTimeMillis() >= deadline);
|
||||
&& (!snapshot.playerAlive()
|
||||
|| budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()));
|
||||
}
|
||||
|
||||
private static Result failure(String step, GameView.AutomationSnapshot snapshot) {
|
||||
return new Result(false, snapshot.deaths(), step + " failed; " + state(snapshot));
|
||||
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) {
|
||||
@@ -194,6 +303,12 @@ final class LevelEightScenario {
|
||||
+ ", doorOpen=" + snapshot.allDoorsOpen()
|
||||
+ ", completed=" + snapshot.completed()
|
||||
+ ", elapsedMs=" + snapshot.elapsedMilliseconds()
|
||||
+ ", deaths=" + snapshot.deaths();
|
||||
+ ", deaths=" + snapshot.deaths()
|
||||
+ ", platformX=" + snapshot.firstMovingPlatformX()
|
||||
+ ", platformY=" + snapshot.firstMovingPlatformY()
|
||||
+ ", onMovingPlatform=" + snapshot.playerOnMovingPlatform()
|
||||
+ ", onGround=" + snapshot.playerOnGround()
|
||||
+ ", platformPowered=" + snapshot.firstMovingPlatformPowered()
|
||||
+ ", platformMovingRight=" + snapshot.firstMovingPlatformMovingRight();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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 LevelPaginationTest {
|
||||
@Test
|
||||
public void nextAndPreviousControlsExposeEveryAuthoredLevel() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.setLanguageForAutomation(GameLanguage.EN);
|
||||
assertEquals(0, view.levelPageIndexForAutomation());
|
||||
assertEquals(4, view.levelPageCountForAutomation());
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("The Volunteer"));
|
||||
assertFalse(view.visibleLocalizedTextForAutomation().contains("Steam Charge"));
|
||||
});
|
||||
|
||||
tap(activity, 1_730f, 965f);
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
List<String> visible = view.visibleLocalizedTextForAutomation();
|
||||
assertEquals(1, view.levelPageIndexForAutomation());
|
||||
assertTrue(visible.contains("Steam Charge"));
|
||||
assertTrue(visible.contains("Ashen Counterweight"));
|
||||
assertTrue(visible.contains("The Whole Weight"));
|
||||
assertTrue(visible.contains("Threefold Proof"));
|
||||
assertTrue(visible.contains("First Return"));
|
||||
assertTrue(visible.contains("Covered Path"));
|
||||
assertFalse(visible.contains("Held Open"));
|
||||
assertFalse(visible.contains("The Volunteer"));
|
||||
assertTrue(visible.contains("Page 2 of 4"));
|
||||
});
|
||||
|
||||
tap(activity, 1_730f, 965f);
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
List<String> visible = view.visibleLocalizedTextForAutomation();
|
||||
assertEquals(2, view.levelPageIndexForAutomation());
|
||||
assertTrue(visible.contains("Held Open"));
|
||||
assertTrue(visible.contains("Clockwork Step"));
|
||||
assertTrue(visible.contains("Remote Volunteer"));
|
||||
assertTrue(visible.contains("Frozen Step"));
|
||||
assertTrue(visible.contains("Living Wire"));
|
||||
assertTrue(visible.contains("Second Hand"));
|
||||
assertTrue(visible.contains("Two Keys"));
|
||||
assertTrue(visible.contains("Clean Restart"));
|
||||
assertTrue(visible.contains("Minimum Cost"));
|
||||
assertTrue(visible.contains("Foundation Relay"));
|
||||
assertFalse(visible.contains("Covered Path"));
|
||||
assertTrue(visible.contains("Page 3 of 4"));
|
||||
});
|
||||
|
||||
tap(activity, 1_730f, 965f);
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
List<String> visible = view.visibleLocalizedTextForAutomation();
|
||||
assertEquals(3, view.levelPageIndexForAutomation());
|
||||
assertTrue(visible.contains("Weight Class"));
|
||||
assertTrue(visible.contains("Stack Order"));
|
||||
assertTrue(visible.contains("Launch Window"));
|
||||
assertTrue(visible.contains("Press Cycle"));
|
||||
assertFalse(visible.contains("Foundation Relay"));
|
||||
assertTrue(visible.contains("Page 4 of 4"));
|
||||
});
|
||||
|
||||
tap(activity, 190f, 965f);
|
||||
activity.onActivity(current ->
|
||||
assertEquals(2, current.gameViewForAutomation().levelPageIndexForAutomation()));
|
||||
tap(activity, 190f, 965f);
|
||||
activity.onActivity(current ->
|
||||
assertEquals(1, current.gameViewForAutomation().levelPageIndexForAutomation()));
|
||||
tap(activity, 190f, 965f);
|
||||
activity.onActivity(current ->
|
||||
assertEquals(0, current.gameViewForAutomation().levelPageIndexForAutomation()));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
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 PhysicsWaveOnePlaythroughTest {
|
||||
@Test public void weightClassOpensFromObjectMassThreeTimes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Driver driver = Driver.from(activity);
|
||||
for (int run = 1; run <= 3; run++) {
|
||||
assertAccepted(run, new LevelPlaytestBot().run(
|
||||
driver, PhysicsWaveOneScenarios.levelThirtyOne(), 55_000L),
|
||||
driver, 0, 0);
|
||||
}
|
||||
driver.restart();
|
||||
GameView.AutomationSnapshot restarted = driver.snapshot();
|
||||
assertEquals(31, restarted.levelNumber());
|
||||
assertEquals(650f, restarted.firstPhysicalObjectX(), 0f);
|
||||
assertEquals(780f, restarted.firstPhysicalObjectY(), 0f);
|
||||
assertEquals(0, restarted.deaths());
|
||||
assertEquals(0, restarted.corpseCount());
|
||||
assertFalse(restarted.allDoorsOpen());
|
||||
assertFalse(restarted.collectibleFound());
|
||||
assertFalse(restarted.inputActive());
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void stackOrderClimbsSupportedObjectsThreeTimes() {
|
||||
runThree(PhysicsWaveOneScenarios.levelThirtyTwo(), 0, 0);
|
||||
}
|
||||
|
||||
@Test public void launchWindowUsesOneSawVolunteerAndLiftThreeTimes() {
|
||||
runThree(PhysicsWaveOneScenarios.levelThirtyThree(), 1, 1);
|
||||
}
|
||||
|
||||
@Test public void pressCycleLeavesOneCrushedBodyThreeTimes() {
|
||||
runThree(PhysicsWaveOneScenarios.levelThirtyFour(), 1, 1);
|
||||
}
|
||||
|
||||
private static void runThree(LevelPlaytestBot.Scenario scenario,
|
||||
int expectedDeaths, int expectedCorpses) {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Driver driver = Driver.from(activity);
|
||||
for (int run = 1; run <= 3; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 55_000L);
|
||||
assertAccepted(run, result, driver, expectedDeaths, expectedCorpses);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertAccepted(int run, LevelPlaytestBot.Result result, Driver driver,
|
||||
int expectedDeaths, int expectedCorpses) {
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run, expectedDeaths, result.deaths());
|
||||
assertEquals("run=" + run, expectedCorpses, result.corpseCount());
|
||||
assertTrue("run=" + run + "; collectible", driver.snapshot().collectibleFound());
|
||||
}
|
||||
|
||||
private static final class Driver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
private Driver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
static Driver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] target = new GameView[1];
|
||||
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
|
||||
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(oneBasedLevelNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean left, boolean right, boolean jump) {
|
||||
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
|
||||
return value[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
instrumentation.runOnMainSync(() ->
|
||||
view.advanceDeterministicAutomation(Math.max(16L, millis)));
|
||||
}
|
||||
|
||||
void restart() {
|
||||
instrumentation.runOnMainSync(view::restartLevelForAutomation);
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
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 ProductionHazardPlaythroughTest {
|
||||
private static final int CONSECUTIVE_RUNS = 3;
|
||||
|
||||
@Test public void crusherLevelCompletesThreeTimes() {
|
||||
prove(ProductionHazardScenarios.levelEleven(), 35_000L, false);
|
||||
}
|
||||
|
||||
@Test public void fireLevelCompletesThreeTimes() {
|
||||
prove(ProductionHazardScenarios.levelTwelve(), 35_000L, false);
|
||||
}
|
||||
|
||||
@Test public void projectileLevelCompletesThreeTimes() {
|
||||
prove(ProductionHazardScenarios.levelThirteen(), 40_000L, false);
|
||||
}
|
||||
|
||||
@Test public void explosionDeliversBodyAndOpensGateThreeTimes() {
|
||||
prove(ProductionHazardScenarios.levelFourteen(), 45_000L, true);
|
||||
}
|
||||
|
||||
private static void prove(LevelPlaytestBot.Scenario scenario, long budget,
|
||||
boolean requiresDoor) {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
AndroidDriver driver = AndroidDriver.from(activity);
|
||||
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, budget);
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run + "; teaching route uses exactly one volunteer",
|
||||
1, result.deaths());
|
||||
assertEquals("run=" + run + "; sacrificed body remains in the level",
|
||||
1, result.corpseCount());
|
||||
if (requiresDoor) {
|
||||
assertTrue("run=" + run + "; blast-delivered body must open the gate",
|
||||
result.doorOpened());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView gameView;
|
||||
|
||||
private AndroidDriver(Instrumentation instrumentation, GameView gameView) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.gameView = gameView;
|
||||
}
|
||||
|
||||
static AndroidDriver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
gameView.enableDeterministicAutomationClock();
|
||||
gameView.startLevelForAutomation(oneBasedLevelNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> snapshot[0] = gameView.automationSnapshot());
|
||||
return snapshot[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
instrumentation.runOnMainSync(() ->
|
||||
gameView.advanceDeterministicAutomation(Math.max(16L, millis)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Intended routes for the first four data-driven production hazards. */
|
||||
final class ProductionHazardScenarios {
|
||||
private ProductionHazardScenarios() {}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelEleven() {
|
||||
return LevelPlaytestBot.Scenario.builder(11, "Level 11 - crusher rhythm")
|
||||
.step("Sacrifice one volunteer to the press", 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 retained body", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Observe the press close for a fresh cycle", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::firstHazardActive)
|
||||
.step("Observe the press resting", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.firstHazardActive())
|
||||
.step("Jump through the resting press", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 900f))
|
||||
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelTwelve() {
|
||||
return LevelPlaytestBot.Scenario.builder(12, "Level 12 - blue fire")
|
||||
.step("Sacrifice one volunteer to the flame", 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 blue flame", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(535f)))
|
||||
.step("Take off before the flame", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 700f))
|
||||
.step("Clear the flame", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 850f))
|
||||
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelThirteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(13, "Level 13 - clockwork projectile")
|
||||
.step("Read the bolt by sacrificing once", 5_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 firing line", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(600f)))
|
||||
.step("Wait for the jump window", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.firstHazardX() >= 850f
|
||||
&& snapshot.firstHazardX() <= 930f))
|
||||
.step("Jump through the bolt line", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Clear the firing line", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_050f))
|
||||
.step("Wait for the returning bolt", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.firstHazardX() >= 1_350f
|
||||
&& snapshot.firstHazardX() <= 1_450f))
|
||||
.step("Jump through the returning bolt", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Clear the second firing line", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_600f))
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelFourteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(14, "Level 14 - explosion delivery")
|
||||
.step("Give one volunteer to the blast", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
|
||||
.step("Observe the delivered body open the gate", 5_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::allDoorsOpen)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the steam charge", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(390f)))
|
||||
.step("Observe the charge rebuild", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::firstHazardActive)
|
||||
.step("Observe the safe blast rest", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.firstHazardActive())
|
||||
.step("Take off before the blast", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Clear the blast", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 820f))
|
||||
.step("Approach the delivered body", 1_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 880f)
|
||||
.step("Jump over the delivered body", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_200f))
|
||||
.step("Pass the opened gate", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_550f)
|
||||
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class PuzzleSystemIntegrationTest {
|
||||
@Test
|
||||
public void pressureBodyPublishesSignalAndDoorEventsThroughProductionInput() {
|
||||
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
new AndroidDriver(instrumentation, view[0]),
|
||||
EarlyLevelScenarios.levelThree(), 25_000L);
|
||||
assertTrue(result.diagnostic(), result.completed());
|
||||
|
||||
PuzzleSystem.Diagnostics[] diagnostics = new PuzzleSystem.Diagnostics[1];
|
||||
instrumentation.runOnMainSync(
|
||||
() -> diagnostics[0] = view[0].puzzleDiagnosticsForAutomation());
|
||||
assertTrue(diagnostics[0].updates() > 0L);
|
||||
assertTrue(diagnostics[0].signalActivatedTransitions() >= 1L);
|
||||
assertTrue(diagnostics[0].doorOpenedTransitions() >= 1L);
|
||||
assertEquals(1, diagnostics[0].openDoors());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
AndroidDriver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view.setAutomationInput(moveLeft, moveRight, jump));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] result = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> result[0] = view.automationSnapshot());
|
||||
return result[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Puzzle integration bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,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,42 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class WeightedObjectIntegrationTest {
|
||||
@Test
|
||||
public void productionInputPushesSharedMassOntoPlateAndSaveCapturesEveryObject() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(17);
|
||||
|
||||
GameView.AutomationSnapshot initial = view.automationSnapshot();
|
||||
assertEquals(5, initial.physicalObjectCount());
|
||||
assertEquals("COUNTERWEIGHT", initial.firstPhysicalObjectType());
|
||||
assertEquals(3, initial.firstPhysicalObjectMass());
|
||||
assertFalse(initial.allDoorsOpen());
|
||||
|
||||
view.setAutomationInput(false, true, false);
|
||||
view.advanceDeterministicAutomation(3_000L);
|
||||
GameView.AutomationSnapshot pushed = view.automationSnapshot();
|
||||
assertTrue(pushed.firstPhysicalObjectX() >= 920f);
|
||||
assertTrue(pushed.allDoorsOpen());
|
||||
|
||||
GameSessionSnapshot saved = view.sessionSnapshotForAutomation();
|
||||
assertEquals(5, saved.objects().size());
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
|
||||
saved.objects().get(0).type());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 WeightedObjectPlaythroughTest {
|
||||
@Test
|
||||
public void allFiveObjectTypesOpenTheMassNineGateThreeTimes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Driver driver = Driver.from(activity);
|
||||
for (int run = 1; run <= 3; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
driver, WeightedObjectScenarios.levelSeventeen(), 50_000L);
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("weighted route is deathless", 0, result.deaths());
|
||||
assertEquals("weighted route does not create bodies", 0, result.corpseCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Driver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
private Driver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
static Driver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] target = new GameView[1];
|
||||
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
|
||||
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(() -> view.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean left, boolean right, boolean jump) {
|
||||
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> snapshot[0] = view.automationSnapshot());
|
||||
return snapshot[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
Thread.sleep(Math.max(32L, millis));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Weighted-object bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Production-input route for the weighted-object teaching puzzle. */
|
||||
final class WeightedObjectScenarios {
|
||||
private WeightedObjectScenarios() {}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelSeventeen() {
|
||||
return LevelPlaytestBot.Scenario.builder(17, "Level 17 - shared mass gate")
|
||||
.step("Observe all authored weight types", 1_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.physicalObjectCount() == 5
|
||||
&& snapshot.firstPhysicalObjectMass() == 3
|
||||
&& "COUNTERWEIGHT".equals(snapshot.firstPhysicalObjectType())
|
||||
&& !snapshot.allDoorsOpen())
|
||||
.step("Approach the counterweight", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Push the missing mass onto the plate", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.firstPhysicalObjectX() >= 920f
|
||||
&& snapshot.allDoorsOpen()))
|
||||
.step("Release input while object mass holds the gate", 1_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.allDoorsOpen()
|
||||
&& snapshot.firstPhysicalObjectX() >= 920f)
|
||||
.step("Take off over the counterweight", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 700f))
|
||||
.step("Land beyond the counterweight", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 1_080f))
|
||||
.step("Jump over the weighted row", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_650f))
|
||||
.step("Pass the mass-nine door and reach the relic", 5_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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,172 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Production-input routes for Foundations levels 23-26. */
|
||||
public final class AdvancedFoundationsScenarios {
|
||||
private AdvancedFoundationsScenarios() {}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentyThree() {
|
||||
return LevelPlaytestBot.Scenario.builder(23, "Level 23 - remote volunteer")
|
||||
.step("Launch one volunteer onto the remote plate", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(1, snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1))
|
||||
.step("Observe the delivered body open the door", 2_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
atMost(1, GameView.AutomationSnapshot::allDoorsOpen))
|
||||
.step("Wait for the crossing volunteer", 2_000L,
|
||||
LevelPlaytestBot.Input.none(), atMost(1, AdvancedFoundationsScenarios::respawned))
|
||||
.step("Stage before the launch saw", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
|
||||
.step("Take off before the launch saw", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the launch saw", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
|
||||
.step("Approach the delivered body", 1_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(780f)))
|
||||
.step("Jump over the body and collect the cache", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.collectibleFound()))
|
||||
.step("Pass the remote gate", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_650f)))
|
||||
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentyFour() {
|
||||
return LevelPlaytestBot.Scenario.builder(24, "Level 24 - frozen step")
|
||||
.step("Freeze one volunteer", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1))
|
||||
.step("Wait for the climbing volunteer", 2_000L,
|
||||
LevelPlaytestBot.Input.none(), atMost(1, AdvancedFoundationsScenarios::respawned))
|
||||
.step("Approach the frozen block", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, snapshot -> snapshot.playerX() >= 420f))
|
||||
.step("Jump toward the frozen block", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(1, snapshot -> snapshot.playerX() >= 670f))
|
||||
.step("Settle on the frozen block", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerY() >= 668f && snapshot.playerY() <= 690f))
|
||||
.step("Jump to the upper route", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 850f && snapshot.playerY() <= 500f))
|
||||
.step("Cross the ledge and collect the cache", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.collectibleFound() && snapshot.playerX() >= 1_300f))
|
||||
.step("Jump to the final ledge", 2_500L, LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_480f && snapshot.playerY() <= 520f))
|
||||
.step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentyFive() {
|
||||
return LevelPlaytestBot.Scenario.builder(25, "Level 25 - living wire")
|
||||
.step("Power the relay with one volunteer", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(1, snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1))
|
||||
.step("Observe the conductive body open the door", 2_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
atMost(1, GameView.AutomationSnapshot::allDoorsOpen))
|
||||
.step("Wait for the crossing volunteer", 2_000L,
|
||||
LevelPlaytestBot.Input.none(), atMost(1, AdvancedFoundationsScenarios::respawned))
|
||||
.step("Stage before the live relay", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Take off before the live relay", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 680f))
|
||||
.step("Clear the conductive body and collect the cache", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_080f && snapshot.collectibleFound()))
|
||||
.step("Pass the powered door", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_470f)))
|
||||
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(1, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentySixIntended() {
|
||||
return timedRoute(false);
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentySixRecovery() {
|
||||
return timedRoute(true);
|
||||
}
|
||||
|
||||
private static LevelPlaytestBot.Scenario timedRoute(boolean recovery) {
|
||||
LevelPlaytestBot.Scenario.Builder route = LevelPlaytestBot.Scenario.builder(26,
|
||||
recovery ? "Level 26 - far-side recovery" : "Level 26 - original timer");
|
||||
route.step("Start the second-hand timer", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(snapshot -> snapshot.allDoorsOpen()
|
||||
&& snapshot.firstMovingPlatformPowered()))
|
||||
.step("Approach the timed ferry", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(800f)))
|
||||
.step("Wait for the outbound boarding phase", 3_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
noDeaths(snapshot -> MovingPlatformLaunchPolicy.readyToBoard(snapshot, 1_000f)))
|
||||
.step("Board the timed ferry", 2_500L, LevelPlaytestBot.Input.rightJump(),
|
||||
noDeaths(snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerOnMovingPlatform()))
|
||||
.step("Ride to the far launch phase", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
noDeaths(snapshot -> MovingPlatformLaunchPolicy.ready(snapshot, 1_250f)))
|
||||
.step("Jump to the far floor", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_600f)));
|
||||
if (recovery) {
|
||||
route.step("Move beneath the high recovery relay", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_650f)))
|
||||
.step("Wait for the far gate to close", 10_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
noDeaths(snapshot -> !snapshot.allDoorsOpen()))
|
||||
.step("Jump into the recovery relay", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
noDeaths(GameView.AutomationSnapshot::allDoorsOpen));
|
||||
} else {
|
||||
route.step("Pass the door on the original timer", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(2_030f)));
|
||||
}
|
||||
return route.step("Stage before the upper route", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(2_050f)))
|
||||
.step("Jump onto the upper route", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
noDeaths(snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 2_250f
|
||||
&& snapshot.playerY() <= 610f))
|
||||
.step("Collect the upper cache", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
noDeaths(snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
|
||||
.step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static LevelPlaytestBot.SnapshotCondition atMost(
|
||||
int deaths, LevelPlaytestBot.SnapshotCondition condition) {
|
||||
return LevelPlaytestBot.whileDeathsAtMost(deaths, condition);
|
||||
}
|
||||
|
||||
private static LevelPlaytestBot.SnapshotCondition noDeaths(
|
||||
LevelPlaytestBot.SnapshotCondition condition) {
|
||||
return atMost(0, condition);
|
||||
}
|
||||
|
||||
private static boolean respawned(GameView.AutomationSnapshot snapshot) {
|
||||
return snapshot.playerAlive() && snapshot.playerX() < 250f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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),
|
||||
CRUSHER_IMPACT(Channel.EFFECTS, 3),
|
||||
FIRE_BURST(Channel.EFFECTS, 3),
|
||||
PROJECTILE_HIT(Channel.EFFECTS, 3),
|
||||
EXPLOSION_BLAST(Channel.EFFECTS, 3),
|
||||
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,37 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Immutable three-goal result for one completed level attempt. */
|
||||
public final class ChallengeResult {
|
||||
private final boolean casualtyEarned;
|
||||
private final boolean timeEarned;
|
||||
private final boolean collectibleAvailable;
|
||||
private final boolean collectibleEarned;
|
||||
private final int stars;
|
||||
|
||||
private ChallengeResult(boolean casualtyEarned, boolean timeEarned,
|
||||
boolean collectibleAvailable, boolean collectibleEarned) {
|
||||
this.casualtyEarned = casualtyEarned;
|
||||
this.timeEarned = timeEarned;
|
||||
this.collectibleAvailable = collectibleAvailable;
|
||||
this.collectibleEarned = collectibleEarned;
|
||||
stars = (casualtyEarned ? 1 : 0) + (timeEarned ? 1 : 0)
|
||||
+ (collectibleEarned ? 1 : 0);
|
||||
}
|
||||
|
||||
public static ChallengeResult evaluate(LevelDefinition level, int deaths,
|
||||
long elapsedMs, boolean collectibleFound) {
|
||||
if (level == null) throw new IllegalArgumentException("level is required");
|
||||
boolean hasCollectible = level.collectible != null;
|
||||
return new ChallengeResult(
|
||||
Math.max(0, deaths) <= level.targetDeaths,
|
||||
Math.max(0L, elapsedMs) <= level.targetSeconds * 1_000L,
|
||||
hasCollectible,
|
||||
!hasCollectible || collectibleFound);
|
||||
}
|
||||
|
||||
public boolean casualtyEarned() { return casualtyEarned; }
|
||||
public boolean timeEarned() { return timeEarned; }
|
||||
public boolean collectibleAvailable() { return collectibleAvailable; }
|
||||
public boolean collectibleEarned() { return collectibleEarned; }
|
||||
public int stars() { return stars; }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Deterministic animation state derived from the character's public physics facts. */
|
||||
public final class CharacterStateMachine {
|
||||
public enum State {
|
||||
IDLE,
|
||||
RUN,
|
||||
JUMP,
|
||||
FALL,
|
||||
TURN,
|
||||
LAND,
|
||||
INTERACT,
|
||||
STUNNED,
|
||||
DEATH
|
||||
}
|
||||
|
||||
private final PhysicsConfig physics;
|
||||
private State state = State.IDLE;
|
||||
private float transientSeconds;
|
||||
private int facing = 1;
|
||||
private boolean wasOnGround;
|
||||
|
||||
public CharacterStateMachine(PhysicsConfig physics) {
|
||||
if (physics == null) throw new IllegalArgumentException("physics is required");
|
||||
this.physics = physics;
|
||||
}
|
||||
|
||||
public void reset(boolean alive, boolean onGround, float velocityX,
|
||||
float velocityY, int facing) {
|
||||
state = baseState(alive, onGround, velocityX, velocityY);
|
||||
transientSeconds = 0f;
|
||||
this.facing = normalizeFacing(facing);
|
||||
wasOnGround = onGround;
|
||||
}
|
||||
|
||||
public void observe(float deltaSeconds, boolean alive, boolean onGround,
|
||||
float velocityX, float velocityY, int facing) {
|
||||
float safeDelta = Math.max(0f, deltaSeconds);
|
||||
int normalizedFacing = normalizeFacing(facing);
|
||||
if (!alive) {
|
||||
state = State.DEATH;
|
||||
transientSeconds = 0f;
|
||||
this.facing = normalizedFacing;
|
||||
wasOnGround = onGround;
|
||||
return;
|
||||
}
|
||||
if ((state == State.TURN || state == State.LAND || state == State.INTERACT
|
||||
|| state == State.STUNNED)
|
||||
&& transientSeconds > 0f) {
|
||||
transientSeconds = Math.max(0f, transientSeconds - safeDelta);
|
||||
this.facing = normalizedFacing;
|
||||
wasOnGround = onGround;
|
||||
if (transientSeconds > 0f && onGround) return;
|
||||
}
|
||||
if (onGround && !wasOnGround) {
|
||||
state = State.LAND;
|
||||
transientSeconds = physics.landSeconds();
|
||||
this.facing = normalizedFacing;
|
||||
wasOnGround = true;
|
||||
return;
|
||||
}
|
||||
if (onGround && normalizedFacing != this.facing) {
|
||||
state = State.TURN;
|
||||
transientSeconds = physics.turnSeconds();
|
||||
this.facing = normalizedFacing;
|
||||
wasOnGround = true;
|
||||
return;
|
||||
}
|
||||
this.facing = normalizedFacing;
|
||||
wasOnGround = onGround;
|
||||
state = baseState(alive, onGround, velocityX, velocityY);
|
||||
}
|
||||
|
||||
public State state() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public void beginInteraction() {
|
||||
if (state == State.DEATH || state == State.STUNNED) return;
|
||||
state = State.INTERACT;
|
||||
transientSeconds = physics.interactSeconds();
|
||||
}
|
||||
|
||||
public void stun() {
|
||||
if (state == State.DEATH) return;
|
||||
state = State.STUNNED;
|
||||
transientSeconds = physics.stunnedSeconds();
|
||||
}
|
||||
|
||||
private static State baseState(boolean alive, boolean onGround,
|
||||
float velocityX, float velocityY) {
|
||||
if (!alive) return State.DEATH;
|
||||
if (!onGround) return velocityY < 0f ? State.JUMP : State.FALL;
|
||||
return Math.abs(velocityX) >= 30f ? State.RUN : State.IDLE;
|
||||
}
|
||||
|
||||
private static int normalizeFacing(int value) {
|
||||
return value < 0 ? -1 : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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,
|
||||
TARGET_COLLECTIBLE,
|
||||
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 ChallengeResult challengeResult;
|
||||
private final int bestStars;
|
||||
private final NextGoal nextGoal;
|
||||
private final long nextGoalValue;
|
||||
|
||||
private CompletionResult(int currentDeaths, long currentTimeMs,
|
||||
int bestDeaths, long bestTimeMs,
|
||||
int targetDeaths, long targetTimeMs,
|
||||
ChallengeResult challengeResult, int bestStars,
|
||||
NextGoal nextGoal, long nextGoalValue) {
|
||||
this.currentDeaths = currentDeaths;
|
||||
this.currentTimeMs = currentTimeMs;
|
||||
this.bestDeaths = bestDeaths;
|
||||
this.bestTimeMs = bestTimeMs;
|
||||
this.targetDeaths = targetDeaths;
|
||||
this.targetTimeMs = targetTimeMs;
|
||||
this.challengeResult = challengeResult;
|
||||
this.bestStars = bestStars;
|
||||
this.nextGoal = nextGoal;
|
||||
this.nextGoalValue = nextGoalValue;
|
||||
}
|
||||
|
||||
public static CompletionResult create(LevelDefinition level,
|
||||
int currentDeaths, long currentTimeMs,
|
||||
int bestDeaths, long bestTimeMs,
|
||||
boolean hasNextLevel) {
|
||||
return create(level, currentDeaths, currentTimeMs, bestDeaths, bestTimeMs,
|
||||
false, false, hasNextLevel);
|
||||
}
|
||||
|
||||
public static CompletionResult create(LevelDefinition level,
|
||||
int currentDeaths, long currentTimeMs,
|
||||
int bestDeaths, long bestTimeMs,
|
||||
boolean currentCollectible,
|
||||
boolean bestCollectible,
|
||||
boolean hasNextLevel) {
|
||||
long targetTimeMs = level.targetSeconds * 1_000L;
|
||||
ChallengeResult currentChallenges = ChallengeResult.evaluate(
|
||||
level, currentDeaths, currentTimeMs, currentCollectible);
|
||||
ChallengeResult bestChallenges = ChallengeResult.evaluate(
|
||||
level, bestDeaths, bestTimeMs, bestCollectible);
|
||||
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 (!bestChallenges.collectibleEarned()) {
|
||||
goal = NextGoal.TARGET_COLLECTIBLE;
|
||||
goalValue = 0L;
|
||||
} 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, currentChallenges, bestChallenges.stars(),
|
||||
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 ChallengeResult challengeResult() { return challengeResult; }
|
||||
public int bestStars() { return bestStars; }
|
||||
public NextGoal nextGoal() { return nextGoal; }
|
||||
public long nextGoalValue() { return nextGoalValue; }
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Owns the bounded lifecycle of puzzle bodies for one active level. */
|
||||
public final class CorpseManager {
|
||||
public enum Transformation {
|
||||
PINNED,
|
||||
FROZEN,
|
||||
CONDUCTIVE,
|
||||
BURNED,
|
||||
CRUSHED
|
||||
}
|
||||
|
||||
public static final class Body implements IWeightedObject {
|
||||
final long id;
|
||||
final String deathType;
|
||||
float x;
|
||||
float y;
|
||||
float width = 72f;
|
||||
float height = 54f;
|
||||
float vx;
|
||||
float vy;
|
||||
boolean frozen;
|
||||
boolean conductive;
|
||||
boolean pinned;
|
||||
boolean burned;
|
||||
boolean crushed;
|
||||
boolean grounded;
|
||||
int supportIndex = -1;
|
||||
|
||||
private Body(long id, String deathType, float x, float y) {
|
||||
this.id = id;
|
||||
this.deathType = deathType;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
public long id() { return id; }
|
||||
public String deathType() { return 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 vx; }
|
||||
public float velocityY() { return vy; }
|
||||
public boolean frozen() { return frozen; }
|
||||
public boolean conductive() { return conductive; }
|
||||
public boolean pinned() { return pinned; }
|
||||
public boolean burned() { return burned; }
|
||||
public boolean crushed() { return crushed; }
|
||||
public boolean activatesPressure() { return !burned; }
|
||||
public boolean grounded() { return grounded; }
|
||||
public int supportIndex() { return supportIndex; }
|
||||
@Override public float weightedX() { return x; }
|
||||
@Override public float weightedY() { return y; }
|
||||
@Override public float weightedWidth() { return width; }
|
||||
@Override public float weightedHeight() { return height; }
|
||||
@Override public int pressureMass() { return 1; }
|
||||
|
||||
public Body position(float x, float y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Body size(float width, float height) {
|
||||
if (width <= 0f || height <= 0f) {
|
||||
throw new IllegalArgumentException("body size must be positive");
|
||||
}
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Body velocity(float x, float y) {
|
||||
vx = x;
|
||||
vy = y;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Body grounded(boolean value) {
|
||||
grounded = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Body supportIndex(int value) {
|
||||
supportIndex = value;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Diagnostics {
|
||||
private final long registered;
|
||||
private final long evicted;
|
||||
private final long removed;
|
||||
private final long resets;
|
||||
private final long clearedByReset;
|
||||
|
||||
private Diagnostics(long registered, long evicted, long removed,
|
||||
long resets, long clearedByReset) {
|
||||
this.registered = registered;
|
||||
this.evicted = evicted;
|
||||
this.removed = removed;
|
||||
this.resets = resets;
|
||||
this.clearedByReset = clearedByReset;
|
||||
}
|
||||
|
||||
public long registered() { return registered; }
|
||||
public long evicted() { return evicted; }
|
||||
public long removed() { return removed; }
|
||||
public long resets() { return resets; }
|
||||
public long clearedByReset() { return clearedByReset; }
|
||||
}
|
||||
|
||||
private final int maximumBodies;
|
||||
private final ArrayList<Body> bodies = new ArrayList<>();
|
||||
private long nextId = 1L;
|
||||
private long registered;
|
||||
private long evicted;
|
||||
private long removed;
|
||||
private long resets;
|
||||
private long clearedByReset;
|
||||
|
||||
public CorpseManager(int maximumBodies) {
|
||||
if (maximumBodies < 1) throw new IllegalArgumentException("maximumBodies must be positive");
|
||||
this.maximumBodies = maximumBodies;
|
||||
}
|
||||
|
||||
public Body register(String deathType, float x, float y) {
|
||||
Body body = new Body(nextId++, safeDeathType(deathType), x, y);
|
||||
bodies.add(body);
|
||||
registered++;
|
||||
if (bodies.size() > maximumBodies) {
|
||||
bodies.remove(0);
|
||||
evicted++;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
public int count() { return bodies.size(); }
|
||||
|
||||
public boolean remove(Body body) {
|
||||
boolean changed = body != null && bodies.remove(body);
|
||||
if (changed) removed++;
|
||||
return changed;
|
||||
}
|
||||
|
||||
public boolean transform(Body body, Transformation transformation) {
|
||||
if (body == null || transformation == null || !bodies.contains(body)) return false;
|
||||
switch (transformation) {
|
||||
case PINNED:
|
||||
body.pinned = true;
|
||||
break;
|
||||
case FROZEN:
|
||||
body.frozen = true;
|
||||
break;
|
||||
case CONDUCTIVE:
|
||||
body.conductive = true;
|
||||
break;
|
||||
case BURNED:
|
||||
body.burned = true;
|
||||
break;
|
||||
case CRUSHED:
|
||||
body.crushed = true;
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
clearedByReset += bodies.size();
|
||||
bodies.clear();
|
||||
resets++;
|
||||
}
|
||||
|
||||
public List<Body> bodies() {
|
||||
return Collections.unmodifiableList(bodies);
|
||||
}
|
||||
|
||||
public Diagnostics diagnostics() {
|
||||
return new Diagnostics(registered, evicted, removed, resets, clearedByReset);
|
||||
}
|
||||
|
||||
private static String safeDeathType(String value) {
|
||||
return value == null || value.trim().isEmpty() ? "GENERIC" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Compressed production-input command stream replayed against a fixed-step target. */
|
||||
public final class DeterministicInputTape {
|
||||
public interface Target {
|
||||
void startLevel(int oneBasedLevelNumber);
|
||||
void setInput(boolean left, boolean right, boolean jump);
|
||||
void advance(long milliseconds);
|
||||
String terminalSignature();
|
||||
}
|
||||
|
||||
public static final class Command {
|
||||
private final boolean left;
|
||||
private final boolean right;
|
||||
private final boolean jump;
|
||||
private final long milliseconds;
|
||||
|
||||
private Command(boolean left, boolean right, boolean jump, long milliseconds) {
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.jump = jump;
|
||||
this.milliseconds = milliseconds;
|
||||
}
|
||||
|
||||
public boolean left() { return left; }
|
||||
public boolean right() { return right; }
|
||||
public boolean jump() { return jump; }
|
||||
public long milliseconds() { return milliseconds; }
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private final int levelNumber;
|
||||
private final ArrayList<Command> commands = new ArrayList<>();
|
||||
|
||||
private Builder(int levelNumber) {
|
||||
if (levelNumber < 1 || levelNumber > LevelCatalog.all().size()) {
|
||||
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,59 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Explicit state holder for one data-bound puzzle door. */
|
||||
public final class DoorController {
|
||||
public enum State {
|
||||
CLOSED,
|
||||
OPEN
|
||||
}
|
||||
|
||||
private final String id;
|
||||
private final List<String> requiredSignals;
|
||||
private State state = State.CLOSED;
|
||||
|
||||
public DoorController(String id, List<String> requiredSignals) {
|
||||
this.id = requireId(id, "id");
|
||||
if (requiredSignals == null) {
|
||||
throw new IllegalArgumentException("requiredSignals must not be null");
|
||||
}
|
||||
ArrayList<String> validated = new ArrayList<>(requiredSignals.size());
|
||||
for (String signal : requiredSignals) {
|
||||
validated.add(requireId(signal, "required signal"));
|
||||
}
|
||||
this.requiredSignals = Collections.unmodifiableList(validated);
|
||||
}
|
||||
|
||||
public String id() { return id; }
|
||||
public List<String> requiredSignals() { return requiredSignals; }
|
||||
public State state() { return state; }
|
||||
public boolean open() { return state == State.OPEN; }
|
||||
|
||||
boolean update(PuzzleSystem system) {
|
||||
boolean shouldOpen = true;
|
||||
for (String signal : requiredSignals) {
|
||||
if (!system.signalActive(signal)) {
|
||||
shouldOpen = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
State next = shouldOpen ? State.OPEN : State.CLOSED;
|
||||
if (next == state) return false;
|
||||
state = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = State.CLOSED;
|
||||
}
|
||||
|
||||
private static String requireId(String value, String name) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,9 @@ public final class EarlyLevelScenarios {
|
||||
EarlyLevelScenarios::respawnedAtStart)
|
||||
.step("Approach the completed bridge", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 470f)
|
||||
.step("Cross the corpse bridge", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
.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)
|
||||
@@ -65,9 +67,16 @@ public final class EarlyLevelScenarios {
|
||||
.step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
EarlyLevelScenarios::respawnedAtStart)
|
||||
.step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 390f)
|
||||
.step("Jump over the saw and plate body", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 700f)
|
||||
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();
|
||||
@@ -76,7 +85,10 @@ public final class EarlyLevelScenarios {
|
||||
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(),
|
||||
snapshot -> snapshot.playerX() >= 800f)
|
||||
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(),
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Production-input routes for the Foundations combination, challenge and finale levels. */
|
||||
public final class FoundationsFinaleScenarios {
|
||||
private FoundationsFinaleScenarios() {}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentySeven() {
|
||||
return LevelPlaytestBot.Scenario.builder(27, "Level 27 - two keys")
|
||||
.step("Leave the pressure key", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1
|
||||
&& !snapshot.allDoorsOpen()))
|
||||
.step("Wait for the relay volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(2, FoundationsFinaleScenarios::respawned))
|
||||
.step("Stage before the pressure key", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(430f)))
|
||||
.step("Cross the pressure key", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(800f)))
|
||||
.step("Leave the conductive key", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.deaths() == 2
|
||||
&& snapshot.corpseCount() == 2))
|
||||
.step("Prove both keys open the door", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(2, GameView.AutomationSnapshot::allDoorsOpen))
|
||||
.step("Wait for the crossing volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(2, FoundationsFinaleScenarios::respawned))
|
||||
.step("Return to the pressure key", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(430f)))
|
||||
.step("Cross the pressure key again", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(800f)))
|
||||
.step("Stage before the conductive key", 1_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(880f)))
|
||||
.step("Take off before the live relay", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 680f))
|
||||
.step("Clear the live key and collect the cache", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_300f && snapshot.collectibleFound()))
|
||||
.step("Pass the AND door", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(1_700f)))
|
||||
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentyEight() {
|
||||
return LevelPlaytestBot.Scenario.builder(28, "Level 28 - clean restart")
|
||||
.step("Deliver the first body", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1))
|
||||
.step("Wait for the freezing volunteer", 2_000L,
|
||||
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Stage before the delivery saw", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
|
||||
.step("Take off before the delivery saw", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the delivery saw", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
|
||||
.step("Jump over the delivered body", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_150f))
|
||||
.step("Freeze the second body", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 2
|
||||
&& snapshot.corpseCount() == 2))
|
||||
.step("Wait for the conductive volunteer", 2_000L,
|
||||
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Stage before the delivery saw again", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
|
||||
.step("Take off before the delivery saw again", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the delivery saw again", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
|
||||
.step("Clear the delivered body again", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_150f)))
|
||||
.step("Stage before the frozen body", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
|
||||
.step("Clear the frozen body", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_430f)))
|
||||
.step("Create the conductive body", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 3
|
||||
&& snapshot.corpseCount() == 3))
|
||||
.step("Wait for the final volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Stage for the final delivery crossing", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
|
||||
.step("Take off for the final delivery crossing", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the delivery saw for the final time", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
|
||||
.step("Clear the delivered body for the final time", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_150f)))
|
||||
.step("Return to the frozen body", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
|
||||
.step("Clear the frozen body again", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_430f)))
|
||||
.step("Stage before the conductive body", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_500f)))
|
||||
.step("Jump over the conductive body", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_950f))
|
||||
.step("Collect the cache", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentyNine() {
|
||||
return LevelPlaytestBot.Scenario.builder(29, "Level 29 - minimum cost")
|
||||
.step("Place the near bridge body", 3_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.deaths() == 1))
|
||||
.step("Wait for the second bridge volunteer", 1_500L,
|
||||
LevelPlaytestBot.Input.none(), atMost(2, FoundationsFinaleScenarios::respawned))
|
||||
.step("Approach the first bridge body", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Place the far pressure body", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, snapshot -> snapshot.deaths() == 2
|
||||
&& snapshot.corpseCount() == 2
|
||||
&& !snapshot.allDoorsOpen()))
|
||||
.step("Wait for the minimum-cost volunteer", 1_500L,
|
||||
LevelPlaytestBot.Input.none(), atMost(2, FoundationsFinaleScenarios::respawned))
|
||||
.step("Return to the corpse bridge", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Cross both retained bodies", 3_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(1_050f)))
|
||||
.step("Start the minimum clock", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.allDoorsOpen()
|
||||
&& snapshot.collectibleFound()))
|
||||
.step("Pass the timed AND door", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(1_700f)))
|
||||
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelThirty() {
|
||||
return LevelPlaytestBot.Scenario.builder(30, "Level 30 - foundation relay")
|
||||
.step("Place the near finale bridge body", 3_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 1))
|
||||
.step("Wait for the far bridge volunteer", 1_500L,
|
||||
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Approach the near finale body", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(400f)))
|
||||
.step("Place the far finale bridge body", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 2))
|
||||
.step("Wait for the pressure volunteer", 1_500L,
|
||||
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Return to the finale bridge", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(400f)))
|
||||
.step("Cross both finale bridge bodies", 3_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
|
||||
.step("Leave the third body on the relay", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 3
|
||||
&& snapshot.corpseCount() == 3
|
||||
&& !snapshot.allDoorsOpen()))
|
||||
.step("Wait for the relay runner", 1_500L, LevelPlaytestBot.Input.none(),
|
||||
atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Return to the finale bridge again", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(400f)))
|
||||
.step("Cross the complete corpse bridge", 3_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
|
||||
.step("Take off before the pressure relay", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the pressure relay body", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_300f))
|
||||
.step("Start both finale controls", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.allDoorsOpen()
|
||||
&& snapshot.firstMovingPlatformPowered()))
|
||||
.step("Wait for the finale boarding phase", 3_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
atMost(3, snapshot -> MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot, 1_650f)))
|
||||
.step("Board the finale ferry", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerOnMovingPlatform()))
|
||||
.step("Ride the finale ferry", 5_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(3, snapshot -> MovingPlatformLaunchPolicy.ready(snapshot, 1_950f)))
|
||||
.step("Jump to the far finale floor", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(2_320f)))
|
||||
.step("Collect the finale cache", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
|
||||
.step("Pass the finale door", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(2_800f)))
|
||||
.step("Reach the Foundations relic", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static LevelPlaytestBot.SnapshotCondition atMost(
|
||||
int deaths, LevelPlaytestBot.SnapshotCondition condition) {
|
||||
return LevelPlaytestBot.whileDeathsAtMost(deaths, condition);
|
||||
}
|
||||
|
||||
private static boolean respawned(GameView.AutomationSnapshot snapshot) {
|
||||
return snapshot.playerAlive() && snapshot.playerX() < 250f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Production-input intended routes for the first authored Foundations wave. */
|
||||
public final class FoundationsScenarios {
|
||||
private FoundationsScenarios() {}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelNineteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(19, "Level 19 - first return")
|
||||
.step("Approach the lower ledge", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(500f)))
|
||||
.step("Clear the lower ledge", 2_500L, LevelPlaytestBot.Input.rightJump(),
|
||||
noDeaths(snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 920f))
|
||||
.step("Release before the high ledge", 1_500L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(980f)))
|
||||
.step("Jump onto the high ledge", 2_500L, LevelPlaytestBot.Input.rightJump(),
|
||||
noDeaths(snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_400f))
|
||||
.step("Collect the cache after both jumps", 1_500L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
|
||||
.step("Land beyond the high ledge", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_450f)))
|
||||
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwenty() {
|
||||
return LevelPlaytestBot.Scenario.builder(20, "Level 20 - covered path")
|
||||
.step("Place the near bridge body", 3_500L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2, snapshot -> snapshot.deaths() == 1))
|
||||
.step("Wait for the second volunteer", 1_500L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2, FoundationsScenarios::respawned))
|
||||
.step("Approach the first bridge body", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Place the far bridge body", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2, snapshot -> snapshot.deaths() == 2))
|
||||
.step("Wait for the crossing volunteer", 1_500L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2, FoundationsScenarios::respawned))
|
||||
.step("Return to the covered path", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Cross both retained bodies", 3_500L, LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(900f)))
|
||||
.step("Approach the cache ledge", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(980f)))
|
||||
.step("Collect the cache", 2_500L, LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentyOne() {
|
||||
return LevelPlaytestBot.Scenario.builder(21, "Level 21 - held open")
|
||||
.step("Leave one volunteer on the plate", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.deaths() == 1))
|
||||
.step("Prove the body holds the door", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
GameView.AutomationSnapshot::allDoorsOpen))
|
||||
.step("Wait for the next volunteer", 1_500L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, FoundationsScenarios::respawned))
|
||||
.step("Stage before the saw", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(470f)))
|
||||
.step("Take off before the saw", 1_500L, LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the saw and retained body", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 820f))
|
||||
.step("Stage before the open door", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(1_020f)))
|
||||
.step("Jump through the cache", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.collectibleFound() && snapshot.allDoorsOpen()))
|
||||
.step("Reach the relic through the held door", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentyTwo() {
|
||||
return LevelPlaytestBot.Scenario.builder(22, "Level 22 - clockwork step")
|
||||
.step("Set the ferry toggle", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(GameView.AutomationSnapshot::firstMovingPlatformPowered))
|
||||
.step("Approach the ferry", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(850f)))
|
||||
.step("Board the moving ferry", 2_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
noDeaths(snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerOnMovingPlatform()))
|
||||
.step("Ride to the far overlap and collect the cache", 5_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
noDeaths(snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnMovingPlatform()
|
||||
&& snapshot.firstMovingPlatformX() >= 1_280f
|
||||
&& snapshot.collectibleFound()))
|
||||
.step("Step onto the far floor", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_650f)))
|
||||
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
noDeaths(GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static LevelPlaytestBot.SnapshotCondition noDeaths(
|
||||
LevelPlaytestBot.SnapshotCondition condition) {
|
||||
return LevelPlaytestBot.whileDeathsAtMost(0, condition);
|
||||
}
|
||||
|
||||
private static boolean respawned(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,226 @@
|
||||
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, crushed, burned;
|
||||
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, y, width, height, velocityX, velocityY, frozen, conductive,
|
||||
pinned, grounded, false, false, supportIndex, deathType);
|
||||
}
|
||||
|
||||
public BodyData(float x, float y, float width, float height,
|
||||
float velocityX, float velocityY, boolean frozen,
|
||||
boolean conductive, boolean pinned, boolean grounded,
|
||||
boolean crushed, boolean burned,
|
||||
int supportIndex, String deathType) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.velocityX = velocityX;
|
||||
this.velocityY = velocityY;
|
||||
this.frozen = frozen;
|
||||
this.conductive = conductive;
|
||||
this.pinned = pinned;
|
||||
this.grounded = grounded;
|
||||
this.crushed = crushed;
|
||||
this.burned = burned;
|
||||
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 boolean crushed() { return crushed; }
|
||||
public boolean burned() { return burned; }
|
||||
public int supportIndex() { return supportIndex; }
|
||||
public String deathType() { return deathType; }
|
||||
}
|
||||
|
||||
public static final class ObjectData {
|
||||
private final LevelDefinition.PhysicalObjectType type;
|
||||
private final float x, y, width, height, velocityX, velocityY;
|
||||
private final int mass;
|
||||
private final boolean grounded;
|
||||
private final int supportIndex;
|
||||
|
||||
public ObjectData(LevelDefinition.PhysicalObjectType type,
|
||||
float x, float y, float width, float height,
|
||||
float velocityX, float velocityY, int mass,
|
||||
boolean grounded, int supportIndex) {
|
||||
this.type = type;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.velocityX = velocityX;
|
||||
this.velocityY = velocityY;
|
||||
this.mass = mass;
|
||||
this.grounded = grounded;
|
||||
this.supportIndex = supportIndex;
|
||||
}
|
||||
|
||||
public LevelDefinition.PhysicalObjectType type() { return type; }
|
||||
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 int mass() { return mass; }
|
||||
public boolean grounded() { return grounded; }
|
||||
public int supportIndex() { return supportIndex; }
|
||||
}
|
||||
|
||||
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 boolean collectibleFound;
|
||||
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<ObjectData> objects = 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 collectibleFound(boolean value) { collectibleFound = value; return this; }
|
||||
public Builder player(PlayerData value) { player = value; return this; }
|
||||
public Builder addBody(BodyData value) { bodies.add(value); return this; }
|
||||
public Builder addObject(ObjectData value) { objects.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 boolean collectibleFound;
|
||||
private final PlayerData player;
|
||||
private final List<BodyData> bodies;
|
||||
private final List<ObjectData> objects;
|
||||
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;
|
||||
collectibleFound = builder.collectibleFound;
|
||||
player = builder.player;
|
||||
bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies));
|
||||
objects = Collections.unmodifiableList(new ArrayList<>(builder.objects));
|
||||
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 boolean collectibleFound() { return collectibleFound; }
|
||||
public PlayerData player() { return player; }
|
||||
public List<BodyData> bodies() { return bodies; }
|
||||
public List<ObjectData> objects() { return objects; }
|
||||
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,357 @@
|
||||
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. Платформенная головоломка, уровней: "
|
||||
+ LevelCatalog.all().size() + "."
|
||||
: "Project Sacrifice. Puzzle platform game with "
|
||||
+ LevelCatalog.all().size() + " levels.";
|
||||
}
|
||||
|
||||
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 previousPage() { return russian() ? "Назад" : "Previous"; }
|
||||
public String nextPage() { return russian() ? "Далее" : "Next"; }
|
||||
public String pageIndicator(int currentPage, int totalPages) {
|
||||
return russian()
|
||||
? "Страница " + currentPage + " из " + totalPages
|
||||
: "Page " + currentPage + " of " + totalPages;
|
||||
}
|
||||
|
||||
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 starRow(int earnedStars) {
|
||||
int safe = Math.max(0, Math.min(3, earnedStars));
|
||||
StringBuilder row = new StringBuilder(3);
|
||||
for (int star = 0; star < 3; star++) row.append(star < safe ? '★' : '☆');
|
||||
return row.toString();
|
||||
}
|
||||
public String completionCollectible(boolean earned, boolean available) {
|
||||
if (!available) {
|
||||
return "★ " + (russian() ? "Основное прохождение" : "Core completion");
|
||||
}
|
||||
return (earned ? "★ " : "☆ ") + (russian()
|
||||
? (earned ? "Тайник найден" : "Тайник не найден")
|
||||
: (earned ? "Cache found" : "Cache not found"));
|
||||
}
|
||||
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 nextGoalCollectible() {
|
||||
return russian() ? "Следующая цель · найти тайник"
|
||||
: "Next goal · find the cache";
|
||||
}
|
||||
|
||||
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
@@ -0,0 +1,47 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Deterministic per-level runtime clock for one immutable hazard definition. */
|
||||
public final class HazardRuntime {
|
||||
private final LevelDefinition.Hazard definition;
|
||||
private float elapsedSeconds;
|
||||
|
||||
public HazardRuntime(LevelDefinition.Hazard definition) {
|
||||
if (definition == null) throw new IllegalArgumentException("definition is required");
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
public LevelDefinition.Hazard definition() { return definition; }
|
||||
public float elapsedSeconds() { return elapsedSeconds; }
|
||||
|
||||
public void update(float deltaSeconds) {
|
||||
if (!Float.isFinite(deltaSeconds) || deltaSeconds < 0f) {
|
||||
throw new IllegalArgumentException("deltaSeconds must be finite and non-negative");
|
||||
}
|
||||
elapsedSeconds += deltaSeconds;
|
||||
if (definition.cycleSeconds > 0f && elapsedSeconds >= definition.cycleSeconds) {
|
||||
elapsedSeconds %= definition.cycleSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean active() {
|
||||
boolean cyclicWindow = definition.type == LevelDefinition.HazardType.CRUSHER
|
||||
|| definition.type == LevelDefinition.HazardType.EXPLOSION;
|
||||
if (!cyclicWindow || definition.cycleSeconds <= 0f
|
||||
|| definition.activeSeconds <= 0f) return true;
|
||||
return elapsedSeconds < definition.activeSeconds;
|
||||
}
|
||||
|
||||
public LevelDefinition.Box currentBox() {
|
||||
if (definition.type != LevelDefinition.HazardType.PROJECTILE) {
|
||||
return definition.box;
|
||||
}
|
||||
return new LevelDefinition.Box(
|
||||
definition.box.x + definition.speedX * elapsedSeconds,
|
||||
definition.box.y + definition.speedY * elapsedSeconds,
|
||||
definition.box.width, definition.box.height);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
elapsedSeconds = 0f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Shared pressure-plate contract for living, retained, and authored objects. */
|
||||
public interface IWeightedObject {
|
||||
float weightedX();
|
||||
float weightedY();
|
||||
float weightedWidth();
|
||||
float weightedHeight();
|
||||
int pressureMass();
|
||||
boolean activatesPressure();
|
||||
}
|
||||
@@ -1,197 +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, 776, 100, 104)), 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", "Jump for the far blue relay if the timed gate closes.",
|
||||
2450, 130, 760, goal(2220, 600), 0, 38,
|
||||
platforms(
|
||||
floor(0, 1040), floor(1510, 940), ledge(1980, 710, 470),
|
||||
moving(910, 770, 320, 34, 380, 0, 4.0f, "clock")
|
||||
),
|
||||
hazards(hazard(PIT, 1040, 880, 470, 200)),
|
||||
switches(
|
||||
sw(TIMED, 560, 810, 90, 70, "clock", 8.0f, 1),
|
||||
sw(TIMED, 1600, 650, 90, 70, "clock", 3.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, 1940), floor(2140, 960), ledge(2420, 440, 680),
|
||||
moving(1800, 790, 290, 34, 0, -410, 10.0f, "lift")
|
||||
),
|
||||
hazards(
|
||||
hazard(SAW, 520, 760, 96, 96, null, 700, -240),
|
||||
hazard(FREEZER, 1110, 776, 100, 104),
|
||||
hazard(ELECTRIC, 1510, 780, 110, 100, "power", 0, 0),
|
||||
hazard(PIT, 1940, 880, 200, 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); }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
public final class LevelPage {
|
||||
private final int totalItems;
|
||||
private final int pageSize;
|
||||
private int pageIndex;
|
||||
|
||||
public LevelPage(int totalItems, int pageSize) {
|
||||
if (totalItems < 1) {
|
||||
throw new IllegalArgumentException("totalItems must be positive");
|
||||
}
|
||||
if (pageSize < 1) {
|
||||
throw new IllegalArgumentException("pageSize must be positive");
|
||||
}
|
||||
this.totalItems = totalItems;
|
||||
this.pageSize = pageSize;
|
||||
}
|
||||
|
||||
public int pageIndex() {
|
||||
return pageIndex;
|
||||
}
|
||||
|
||||
public int pageCount() {
|
||||
return (totalItems + pageSize - 1) / pageSize;
|
||||
}
|
||||
|
||||
public int firstIndex() {
|
||||
return pageIndex * pageSize;
|
||||
}
|
||||
|
||||
public int endExclusive() {
|
||||
return Math.min(totalItems, firstIndex() + pageSize);
|
||||
}
|
||||
|
||||
public boolean hasPrevious() {
|
||||
return pageIndex > 0;
|
||||
}
|
||||
|
||||
public boolean hasNext() {
|
||||
return pageIndex + 1 < pageCount();
|
||||
}
|
||||
|
||||
public void previous() {
|
||||
if (hasPrevious()) {
|
||||
pageIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
public void next() {
|
||||
if (hasNext()) {
|
||||
pageIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
public void selectIndex(int index) {
|
||||
if (index < 0 || index >= totalItems) {
|
||||
throw new IllegalArgumentException("index outside level page");
|
||||
}
|
||||
pageIndex = index / pageSize;
|
||||
}
|
||||
}
|
||||
@@ -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,51 +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)) {
|
||||
driver.setInput(false, false, false);
|
||||
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) {
|
||||
@@ -173,6 +257,8 @@ public final class LevelPlaytestBot {
|
||||
+ ", completed=" + snapshot.completed()
|
||||
+ ", deaths=" + snapshot.deaths()
|
||||
+ ", corpses=" + snapshot.corpseCount()
|
||||
+ ", firstCorpseX=" + snapshot.firstCorpseX()
|
||||
+ ", firstHazardX=" + snapshot.firstHazardX()
|
||||
+ ", allDoorsOpen=" + snapshot.allDoorsOpen();
|
||||
}
|
||||
|
||||
@@ -190,8 +276,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,35 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Resolves a falling authored object onto the first crossed object beneath it. */
|
||||
final class PhysicalObjectStackResolver {
|
||||
private static final float CONTACT_EPSILON = 1.5f;
|
||||
|
||||
private PhysicalObjectStackResolver() {}
|
||||
|
||||
static boolean settleFalling(PhysicalObjectState moving, float previousY,
|
||||
Iterable<PhysicalObjectState> objects) {
|
||||
if (moving == null || objects == null || moving.vy < 0f) return false;
|
||||
float previousBottom = previousY + moving.height;
|
||||
float currentBottom = moving.y + moving.height;
|
||||
PhysicalObjectState nearest = null;
|
||||
float nearestTop = Float.POSITIVE_INFINITY;
|
||||
for (PhysicalObjectState support : objects) {
|
||||
if (support == null || support == moving) continue;
|
||||
if (moving.x >= support.x + support.width
|
||||
|| moving.x + moving.width <= support.x) continue;
|
||||
if (previousBottom > support.y + CONTACT_EPSILON) continue;
|
||||
if (currentBottom + CONTACT_EPSILON < support.y) continue;
|
||||
if (moving.y >= support.y) continue;
|
||||
if (support.y < nearestTop) {
|
||||
nearest = support;
|
||||
nearestTop = support.y;
|
||||
}
|
||||
}
|
||||
if (nearest == null) return false;
|
||||
moving.y = nearest.y - moving.height;
|
||||
moving.vy = 0f;
|
||||
moving.grounded = true;
|
||||
moving.supportIndex = -1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Mutable deterministic runtime for one authored weighted object. */
|
||||
public final class PhysicalObjectState implements IWeightedObject {
|
||||
final LevelDefinition.PhysicalObjectType type;
|
||||
final int mass;
|
||||
float x, y, width, height, vx, vy;
|
||||
boolean grounded;
|
||||
int supportIndex = -1;
|
||||
|
||||
public PhysicalObjectState(LevelDefinition.PhysicalObject definition) {
|
||||
if (definition == null) throw new IllegalArgumentException("definition is required");
|
||||
type = definition.type;
|
||||
mass = definition.mass;
|
||||
x = definition.box.x;
|
||||
y = definition.box.y;
|
||||
width = definition.box.width;
|
||||
height = definition.box.height;
|
||||
}
|
||||
|
||||
PhysicalObjectState(GameSessionSnapshot.ObjectData saved) {
|
||||
type = saved.type();
|
||||
mass = saved.mass();
|
||||
x = saved.x();
|
||||
y = saved.y();
|
||||
width = saved.width();
|
||||
height = saved.height();
|
||||
vx = saved.velocityX();
|
||||
vy = saved.velocityY();
|
||||
grounded = saved.grounded();
|
||||
supportIndex = saved.supportIndex();
|
||||
}
|
||||
|
||||
public PhysicalObjectState position(float valueX, float valueY) {
|
||||
x = valueX;
|
||||
y = valueY;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PhysicalObjectState velocity(float valueX, float valueY) {
|
||||
vx = valueX;
|
||||
vy = valueY;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PhysicalObjectState grounded(boolean value) { grounded = value; return this; }
|
||||
public PhysicalObjectState supportIndex(int value) { supportIndex = value; return this; }
|
||||
|
||||
public LevelDefinition.PhysicalObjectType type() { return type; }
|
||||
public float velocityX() { return vx; }
|
||||
public float velocityY() { return vy; }
|
||||
public boolean grounded() { return grounded; }
|
||||
public int supportIndex() { return supportIndex; }
|
||||
|
||||
@Override public float weightedX() { return x; }
|
||||
@Override public float weightedY() { return y; }
|
||||
@Override public float weightedWidth() { return width; }
|
||||
@Override public float weightedHeight() { return height; }
|
||||
@Override public int pressureMass() { return mass; }
|
||||
@Override public boolean activatesPressure() { return true; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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, 0.10f, 0.12f, 0.24f, 0.32f, 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 float turnSeconds;
|
||||
private final float landSeconds;
|
||||
private final float interactSeconds;
|
||||
private final float stunnedSeconds;
|
||||
private final int maximumBodies;
|
||||
|
||||
private PhysicsConfig(int version, float fixedStepSeconds, float gravity,
|
||||
float runSpeed, float jumpSpeed, float coyoteSeconds,
|
||||
float jumpBufferSeconds, float respawnSeconds,
|
||||
float turnSeconds, float landSeconds, float interactSeconds,
|
||||
float stunnedSeconds, 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.turnSeconds = turnSeconds;
|
||||
this.landSeconds = landSeconds;
|
||||
this.interactSeconds = interactSeconds;
|
||||
this.stunnedSeconds = stunnedSeconds;
|
||||
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 float turnSeconds() { return turnSeconds; }
|
||||
public float landSeconds() { return landSeconds; }
|
||||
public float interactSeconds() { return interactSeconds; }
|
||||
public float stunnedSeconds() { return stunnedSeconds; }
|
||||
public int maximumBodies() { return maximumBodies; }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Production-input routes for the first Physics-world authoring wave. */
|
||||
public final class PhysicsWaveOneScenarios {
|
||||
private PhysicsWaveOneScenarios() {}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelThirtyOne() {
|
||||
return LevelPlaytestBot.Scenario.builder(31, "Level 31 - weight class")
|
||||
.step("Observe the mass-three object", 1_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.physicalObjectCount() == 1
|
||||
&& snapshot.firstPhysicalObjectMass() == 3
|
||||
&& !snapshot.allDoorsOpen())
|
||||
.step("Approach the counterweight", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Push mass onto the plate", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(0, snapshot -> snapshot.firstPhysicalObjectX() >= 920f
|
||||
&& snapshot.allDoorsOpen()))
|
||||
.step("Release while mass holds the door", 1_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
aliveAtMost(0, GameView.AutomationSnapshot::allDoorsOpen))
|
||||
.step("Take off over the weight", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
aliveAtMost(0, snapshot -> !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Land beyond the plate", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(1_200f)))
|
||||
.step("Collect the cache beyond the door", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(0, snapshot -> snapshot.collectibleFound()
|
||||
&& snapshot.playerX() >= 1_550f))
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(0, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelThirtyTwo() {
|
||||
return LevelPlaytestBot.Scenario.builder(32, "Level 32 - stack order")
|
||||
.step("Observe the supported three-object stack", 1_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.physicalObjectCount() == 3
|
||||
&& snapshot.firstPhysicalObjectY() >= 799f
|
||||
&& snapshot.firstPhysicalObjectY() <= 801f)
|
||||
.step("Stage before the stack", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(480f)))
|
||||
.step("Jump onto the lower object", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
aliveAtMost(0, snapshot -> snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 600f && snapshot.playerY() <= 700f))
|
||||
.step("Climb to the top object", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
aliveAtMost(0, snapshot -> snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 720f && snapshot.playerY() <= 550f))
|
||||
.step("Jump from the stack to the gallery", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
aliveAtMost(0, snapshot -> snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 1_030f && snapshot.playerY() <= 500f
|
||||
&& snapshot.collectibleFound()))
|
||||
.step("Cross the high gallery", 5_000L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(0, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelThirtyThree() {
|
||||
return LevelPlaytestBot.Scenario.builder(33, "Level 33 - launch window")
|
||||
.step("Read one saw launch", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1))
|
||||
.step("Observe the delivered body", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
aliveAtMost(1, snapshot -> snapshot.firstCorpseX() >= 900f))
|
||||
.step("Wait for the lift runner", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
aliveAtMost(1, PhysicsWaveOneScenarios::respawned))
|
||||
.step("Stage before the launch saw", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
|
||||
.step("Take off before the launch saw", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
aliveAtMost(1, snapshot -> !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 620f))
|
||||
.step("Clear the launch saw", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, snapshot -> snapshot.playerX() >= 760f))
|
||||
.step("Stage before the delivered body", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(820f)))
|
||||
.step("Clear the delivered body", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_200f)))
|
||||
.step("Approach the lift", 1_500L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_240f)))
|
||||
.step("Wait for the low lift phase", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
aliveAtMost(1, snapshot -> snapshot.firstMovingPlatformY() >= 770f))
|
||||
.step("Board the lift", 2_500L, LevelPlaytestBot.Input.rightJump(),
|
||||
aliveAtMost(1, snapshot -> snapshot.playerOnGround()
|
||||
&& snapshot.playerOnMovingPlatform()))
|
||||
.step("Ride to the launch window", 5_000L, LevelPlaytestBot.Input.none(),
|
||||
aliveAtMost(1, snapshot -> snapshot.playerOnGround()
|
||||
&& snapshot.playerOnMovingPlatform()
|
||||
&& snapshot.firstMovingPlatformY() <= 430f))
|
||||
.step("Jump onto the high gallery", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
aliveAtMost(1, snapshot -> snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 1_750f && snapshot.playerY() <= 400f))
|
||||
.step("Collect the upper cache", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, snapshot -> snapshot.collectibleFound()))
|
||||
.step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelThirtyFour() {
|
||||
return LevelPlaytestBot.Scenario.builder(34, "Level 34 - press cycle")
|
||||
.step("Leave one volunteer in the press", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1
|
||||
&& snapshot.firstCorpseCrushed()
|
||||
&& snapshot.firstCorpseActivatesPressure()))
|
||||
.step("Wait for the cycle runner", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
aliveAtMost(1, PhysicsWaveOneScenarios::respawned))
|
||||
.step("Approach the occupied press", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(500f)))
|
||||
.step("Observe the press close", 5_000L, LevelPlaytestBot.Input.none(),
|
||||
aliveAtMost(1, GameView.AutomationSnapshot::firstHazardActive))
|
||||
.step("Wait for the full rest", 5_000L, LevelPlaytestBot.Input.none(),
|
||||
aliveAtMost(1, snapshot -> !snapshot.firstHazardActive()))
|
||||
.step("Jump through the resting press", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
aliveAtMost(1, snapshot -> snapshot.playerX() >= 930f))
|
||||
.step("Collect the press cache", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, snapshot -> snapshot.collectibleFound()))
|
||||
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
|
||||
aliveAtMost(1, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static LevelPlaytestBot.SnapshotCondition aliveAtMost(
|
||||
int deaths, LevelPlaytestBot.SnapshotCondition condition) {
|
||||
return LevelPlaytestBot.whileDeathsAtMost(deaths,
|
||||
snapshot -> snapshot.playerAlive() && condition.reached(snapshot));
|
||||
}
|
||||
|
||||
private static boolean respawned(GameView.AutomationSnapshot snapshot) {
|
||||
return snapshot.playerAlive() && snapshot.playerX() < 250f;
|
||||
}
|
||||
}
|
||||
@@ -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,31 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Pure mass aggregation; it deliberately has no knowledge of object kinds. */
|
||||
public final class PressureMassResolver {
|
||||
private PressureMassResolver() {}
|
||||
|
||||
public static boolean active(LevelDefinition.Box plate, int requiredMass,
|
||||
Iterable<? extends IWeightedObject> objects) {
|
||||
int mass = 0;
|
||||
if (plate == null || objects == null || requiredMass <= 0) return false;
|
||||
for (IWeightedObject object : objects) {
|
||||
if (object == null || !object.activatesPressure()) continue;
|
||||
if (!intersects(object, plate)) continue;
|
||||
mass += Math.max(0, object.pressureMass());
|
||||
if (mass >= requiredMass) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static int contribution(LevelDefinition.Box plate, IWeightedObject object) {
|
||||
if (plate == null || object == null || !object.activatesPressure()) return 0;
|
||||
return intersects(object, plate) ? Math.max(0, object.pressureMass()) : 0;
|
||||
}
|
||||
|
||||
private static boolean intersects(IWeightedObject object, LevelDefinition.Box box) {
|
||||
return object.weightedX() < box.right()
|
||||
&& object.weightedX() + object.weightedWidth() > box.x
|
||||
&& object.weightedY() < box.bottom()
|
||||
&& object.weightedY() + object.weightedHeight() > box.y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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 CRUSHER_IMPACT: return variant == 0 ? ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD
|
||||
: variant == 1 ? ToneGenerator.TONE_CDMA_ABBR_INTERCEPT
|
||||
: ToneGenerator.TONE_CDMA_SOFT_ERROR_LITE;
|
||||
case FIRE_BURST: return variant == 0 ? ToneGenerator.TONE_CDMA_ALERT_AUTOREDIAL_LITE
|
||||
: variant == 1 ? ToneGenerator.TONE_CDMA_ALERT_NETWORK_LITE
|
||||
: ToneGenerator.TONE_CDMA_ALERT_INCALL_LITE;
|
||||
case PROJECTILE_HIT: return variant == 0 ? ToneGenerator.TONE_SUP_ERROR
|
||||
: variant == 1 ? ToneGenerator.TONE_SUP_CONGESTION
|
||||
: ToneGenerator.TONE_SUP_RADIO_ACK;
|
||||
case EXPLOSION_BLAST: return variant == 0 ? ToneGenerator.TONE_CDMA_HIGH_PBX_L
|
||||
: variant == 1 ? ToneGenerator.TONE_CDMA_MED_PBX_L
|
||||
: ToneGenerator.TONE_CDMA_LOW_PBX_L;
|
||||
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 CRUSHER_IMPACT: return 170 + variant * 35;
|
||||
case FIRE_BURST: return 145 + variant * 28;
|
||||
case PROJECTILE_HIT: return 105 + variant * 22;
|
||||
case EXPLOSION_BLAST: return 230 + variant * 40;
|
||||
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 = 3;
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
public ProgressStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
migrateIfSupported();
|
||||
}
|
||||
|
||||
public int unlockedCount() {
|
||||
@@ -16,27 +18,117 @@ public final class ProgressStore {
|
||||
}
|
||||
|
||||
public boolean isCompleted(int levelNumber) {
|
||||
return preferences.getBoolean(key(levelNumber, "completed"), false);
|
||||
return isCompleted(levelId(levelNumber));
|
||||
}
|
||||
|
||||
public boolean isCompleted(String levelId) {
|
||||
return preferences.getBoolean(idKey(levelId, "completed"), false);
|
||||
}
|
||||
|
||||
public int bestDeaths(int levelNumber) {
|
||||
return preferences.getInt(key(levelNumber, "deaths"), Integer.MAX_VALUE);
|
||||
return bestDeaths(levelId(levelNumber));
|
||||
}
|
||||
|
||||
public int bestDeaths(String levelId) {
|
||||
return preferences.getInt(idKey(levelId, "deaths"), Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
public long bestTimeMs(int levelNumber) {
|
||||
return preferences.getLong(key(levelNumber, "time"), Long.MAX_VALUE);
|
||||
return bestTimeMs(levelId(levelNumber));
|
||||
}
|
||||
|
||||
public long bestTimeMs(String levelId) {
|
||||
return preferences.getLong(idKey(levelId, "time"), Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
public boolean collectibleFound(String levelId) {
|
||||
return preferences.getBoolean(idKey(levelId, "collectible"), false);
|
||||
}
|
||||
|
||||
public void recordCompletion(int levelNumber, int deaths, long elapsedMs) {
|
||||
recordCompletion(LevelCatalog.get(levelNumber - 1), deaths, elapsedMs, false);
|
||||
}
|
||||
|
||||
public void recordCompletion(LevelDefinition level, int deaths, long elapsedMs,
|
||||
boolean collectibleFound) {
|
||||
if (level == null) throw new IllegalArgumentException("level is required");
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
editor.putBoolean(key(levelNumber, "completed"), true);
|
||||
editor.putInt(key(levelNumber, "deaths"), Math.min(deaths, bestDeaths(levelNumber)));
|
||||
editor.putLong(key(levelNumber, "time"), Math.min(elapsedMs, bestTimeMs(levelNumber)));
|
||||
editor.putInt("unlocked", Math.min(LevelCatalog.all().size(), Math.max(unlockedCount(), levelNumber + 1)));
|
||||
editor.putBoolean(idKey(level.levelId, "completed"), true);
|
||||
editor.putInt(idKey(level.levelId, "deaths"),
|
||||
Math.min(Math.max(0, deaths), bestDeaths(level.levelId)));
|
||||
editor.putLong(idKey(level.levelId, "time"),
|
||||
Math.min(Math.max(0L, elapsedMs), bestTimeMs(level.levelId)));
|
||||
if (collectibleFound || collectibleFound(level.levelId)) {
|
||||
editor.putBoolean(idKey(level.levelId, "collectible"), true);
|
||||
}
|
||||
editor.putInt("unlocked", Math.min(LevelCatalog.all().size(),
|
||||
Math.max(unlockedCount(), level.number + 1)));
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public int earnedStars(LevelDefinition level) {
|
||||
if (level == null || !isCompleted(level.levelId)) return 0;
|
||||
return ChallengeResult.evaluate(level, bestDeaths(level.levelId),
|
||||
bestTimeMs(level.levelId), collectibleFound(level.levelId)).stars();
|
||||
}
|
||||
|
||||
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) {
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
if (version < 3) migrateNumericKeys(editor);
|
||||
editor.putInt("save_version", CURRENT_SAVE_VERSION).apply();
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateNumericKeys(SharedPreferences.Editor editor) {
|
||||
for (LevelDefinition level : LevelCatalog.all()) {
|
||||
copyBooleanIfPresent(editor, key(level.number, "completed"),
|
||||
idKey(level.levelId, "completed"));
|
||||
copyIntIfPresent(editor, key(level.number, "deaths"),
|
||||
idKey(level.levelId, "deaths"));
|
||||
copyLongIfPresent(editor, key(level.number, "time"),
|
||||
idKey(level.levelId, "time"));
|
||||
}
|
||||
}
|
||||
|
||||
private void copyBooleanIfPresent(SharedPreferences.Editor editor,
|
||||
String oldKey, String newKey) {
|
||||
if (preferences.contains(oldKey) && !preferences.contains(newKey)) {
|
||||
editor.putBoolean(newKey, preferences.getBoolean(oldKey, false));
|
||||
}
|
||||
}
|
||||
|
||||
private void copyIntIfPresent(SharedPreferences.Editor editor,
|
||||
String oldKey, String newKey) {
|
||||
if (preferences.contains(oldKey) && !preferences.contains(newKey)) {
|
||||
editor.putInt(newKey, preferences.getInt(oldKey, Integer.MAX_VALUE));
|
||||
}
|
||||
}
|
||||
|
||||
private void copyLongIfPresent(SharedPreferences.Editor editor,
|
||||
String oldKey, String newKey) {
|
||||
if (preferences.contains(oldKey) && !preferences.contains(newKey)) {
|
||||
editor.putLong(newKey, preferences.getLong(oldKey, Long.MAX_VALUE));
|
||||
}
|
||||
}
|
||||
|
||||
private static String levelId(int levelNumber) {
|
||||
if (levelNumber >= 1 && levelNumber <= LevelCatalog.all().size()) {
|
||||
return LevelCatalog.get(levelNumber - 1).levelId;
|
||||
}
|
||||
return LevelDefinition.defaultLevelId(levelNumber);
|
||||
}
|
||||
|
||||
private static String key(int levelNumber, String suffix) {
|
||||
return "level_" + levelNumber + "_" + suffix;
|
||||
}
|
||||
|
||||
private static String idKey(String levelId, String suffix) {
|
||||
return "level_id_" + levelId + "_" + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Deterministic signal-channel state updated from one frame of puzzle inputs. */
|
||||
public final class PuzzleSystem {
|
||||
public enum SourceKind {
|
||||
TOGGLE,
|
||||
PRESSURE,
|
||||
TIMED,
|
||||
RELAY
|
||||
}
|
||||
|
||||
public static final class Input {
|
||||
private final String sourceId;
|
||||
private final String channelId;
|
||||
private final SourceKind kind;
|
||||
private final boolean active;
|
||||
private final float durationSeconds;
|
||||
|
||||
private Input(String sourceId, String channelId, SourceKind kind, boolean active,
|
||||
float durationSeconds) {
|
||||
this.sourceId = requireId(sourceId, "sourceId");
|
||||
this.channelId = requireId(channelId, "channelId");
|
||||
this.kind = kind;
|
||||
this.active = active;
|
||||
this.durationSeconds = durationSeconds;
|
||||
}
|
||||
|
||||
public static Input toggle(String sourceId, String channelId, boolean active) {
|
||||
return new Input(sourceId, channelId, SourceKind.TOGGLE, active, 0f);
|
||||
}
|
||||
|
||||
public static Input pressure(String sourceId, String channelId, boolean active) {
|
||||
return new Input(sourceId, channelId, SourceKind.PRESSURE, active, 0f);
|
||||
}
|
||||
|
||||
public static Input timed(String sourceId, String channelId, boolean active,
|
||||
float durationSeconds) {
|
||||
if (durationSeconds <= 0f) {
|
||||
throw new IllegalArgumentException("durationSeconds must be positive");
|
||||
}
|
||||
return new Input(sourceId, channelId, SourceKind.TIMED, active, durationSeconds);
|
||||
}
|
||||
|
||||
public static Input relay(String sourceId, String channelId, boolean active) {
|
||||
return new Input(sourceId, channelId, SourceKind.RELAY, active, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Event {
|
||||
public enum Kind {
|
||||
SIGNAL_ACTIVATED,
|
||||
SIGNAL_DEACTIVATED,
|
||||
DOOR_OPENED,
|
||||
DOOR_CLOSED
|
||||
}
|
||||
|
||||
private final Kind kind;
|
||||
private final String targetId;
|
||||
|
||||
private Event(Kind kind, String targetId) {
|
||||
this.kind = kind;
|
||||
this.targetId = targetId;
|
||||
}
|
||||
|
||||
public Kind kind() { return kind; }
|
||||
public String channelId() { return targetId; }
|
||||
public String targetId() { return targetId; }
|
||||
}
|
||||
|
||||
public static final class State {
|
||||
private final Map<String, Boolean> toggleSignals;
|
||||
private final Map<String, Float> timedSignals;
|
||||
|
||||
private State(Map<String, Boolean> toggleSignals,
|
||||
Map<String, Float> timedSignals) {
|
||||
this.toggleSignals = Collections.unmodifiableMap(
|
||||
new HashMap<>(toggleSignals));
|
||||
this.timedSignals = Collections.unmodifiableMap(
|
||||
new HashMap<>(timedSignals));
|
||||
}
|
||||
|
||||
public Map<String, Boolean> toggleSignals() { return toggleSignals; }
|
||||
public Map<String, Float> timedSignals() { return timedSignals; }
|
||||
}
|
||||
|
||||
public static final class Diagnostics {
|
||||
private final long updates;
|
||||
private final long signalActivatedTransitions;
|
||||
private final long signalDeactivatedTransitions;
|
||||
private final long doorOpenedTransitions;
|
||||
private final long doorClosedTransitions;
|
||||
private final int activeSignals;
|
||||
private final int openDoors;
|
||||
|
||||
private Diagnostics(long updates, long signalActivatedTransitions,
|
||||
long signalDeactivatedTransitions, long doorOpenedTransitions,
|
||||
long doorClosedTransitions, int activeSignals, int openDoors) {
|
||||
this.updates = updates;
|
||||
this.signalActivatedTransitions = signalActivatedTransitions;
|
||||
this.signalDeactivatedTransitions = signalDeactivatedTransitions;
|
||||
this.doorOpenedTransitions = doorOpenedTransitions;
|
||||
this.doorClosedTransitions = doorClosedTransitions;
|
||||
this.activeSignals = activeSignals;
|
||||
this.openDoors = openDoors;
|
||||
}
|
||||
|
||||
public long updates() { return updates; }
|
||||
public long signalActivatedTransitions() { return signalActivatedTransitions; }
|
||||
public long signalDeactivatedTransitions() { return signalDeactivatedTransitions; }
|
||||
public long doorOpenedTransitions() { return doorOpenedTransitions; }
|
||||
public long doorClosedTransitions() { return doorClosedTransitions; }
|
||||
public int activeSignals() { return activeSignals; }
|
||||
public int openDoors() { return openDoors; }
|
||||
}
|
||||
|
||||
private final Map<String, Boolean> toggleChannels = new HashMap<>();
|
||||
private final Map<String, Float> timedChannels = new HashMap<>();
|
||||
private final Map<String, Boolean> previousSourceActivity = new HashMap<>();
|
||||
private final Map<String, SourceKind> sourceKinds = new HashMap<>();
|
||||
private final Map<String, String> sourceChannels = new HashMap<>();
|
||||
private final Set<String> knownChannels = new LinkedHashSet<>();
|
||||
private final List<DoorController> doors;
|
||||
private List<Event> events = Collections.emptyList();
|
||||
private long updates;
|
||||
private long signalActivatedTransitions;
|
||||
private long signalDeactivatedTransitions;
|
||||
private long doorOpenedTransitions;
|
||||
private long doorClosedTransitions;
|
||||
|
||||
public PuzzleSystem() {
|
||||
this(Collections.emptyList());
|
||||
}
|
||||
|
||||
public PuzzleSystem(List<DoorController> doors) {
|
||||
if (doors == null) throw new IllegalArgumentException("doors must not be null");
|
||||
ArrayList<DoorController> validated = new ArrayList<>(doors.size());
|
||||
LinkedHashSet<String> ids = new LinkedHashSet<>();
|
||||
for (DoorController door : doors) {
|
||||
if (door == null) throw new IllegalArgumentException("door must not be null");
|
||||
if (!ids.add(door.id())) throw new IllegalArgumentException("duplicate door " + door.id());
|
||||
validated.add(door);
|
||||
}
|
||||
this.doors = Collections.unmodifiableList(validated);
|
||||
}
|
||||
|
||||
public void update(float deltaSeconds, List<Input> inputs) {
|
||||
if (deltaSeconds < 0f) throw new IllegalArgumentException("deltaSeconds must not be negative");
|
||||
if (inputs == null) throw new IllegalArgumentException("inputs must not be null");
|
||||
updates++;
|
||||
|
||||
LinkedHashSet<String> affected = new LinkedHashSet<>(knownChannels);
|
||||
for (Input input : inputs) {
|
||||
if (input == null) throw new IllegalArgumentException("input must not be null");
|
||||
affected.add(input.channelId);
|
||||
}
|
||||
|
||||
Map<String, Boolean> before = new HashMap<>();
|
||||
for (String channelId : affected) before.put(channelId, signalActive(channelId));
|
||||
|
||||
for (Map.Entry<String, Float> timer : new ArrayList<>(timedChannels.entrySet())) {
|
||||
timedChannels.put(timer.getKey(),
|
||||
Math.max(0f, timer.getValue() - deltaSeconds));
|
||||
}
|
||||
|
||||
for (Input input : inputs) {
|
||||
knownChannels.add(input.channelId);
|
||||
boolean previous = Boolean.TRUE.equals(previousSourceActivity.get(input.sourceId));
|
||||
if (input.kind == SourceKind.TOGGLE && input.active && !previous) {
|
||||
toggleChannels.put(input.channelId,
|
||||
!Boolean.TRUE.equals(toggleChannels.get(input.channelId)));
|
||||
}
|
||||
if (input.kind == SourceKind.TIMED && input.active && !previous) {
|
||||
timedChannels.put(input.channelId, input.durationSeconds);
|
||||
}
|
||||
sourceKinds.put(input.sourceId, input.kind);
|
||||
sourceChannels.put(input.sourceId, input.channelId);
|
||||
previousSourceActivity.put(input.sourceId, input.active);
|
||||
}
|
||||
|
||||
ArrayList<Event> nextEvents = new ArrayList<>();
|
||||
for (String channelId : affected) {
|
||||
boolean wasActive = Boolean.TRUE.equals(before.get(channelId));
|
||||
boolean isActive = signalActive(channelId);
|
||||
if (wasActive != isActive) {
|
||||
nextEvents.add(new Event(isActive
|
||||
? Event.Kind.SIGNAL_ACTIVATED
|
||||
: Event.Kind.SIGNAL_DEACTIVATED, channelId));
|
||||
if (isActive) signalActivatedTransitions++;
|
||||
else signalDeactivatedTransitions++;
|
||||
}
|
||||
}
|
||||
for (DoorController door : doors) {
|
||||
if (door.update(this)) {
|
||||
nextEvents.add(new Event(door.open()
|
||||
? Event.Kind.DOOR_OPENED
|
||||
: Event.Kind.DOOR_CLOSED, door.id()));
|
||||
if (door.open()) doorOpenedTransitions++;
|
||||
else doorClosedTransitions++;
|
||||
}
|
||||
}
|
||||
events = Collections.unmodifiableList(nextEvents);
|
||||
}
|
||||
|
||||
public boolean signalActive(String channelId) {
|
||||
if (channelId == null || channelId.isEmpty()) return true;
|
||||
if (Boolean.TRUE.equals(toggleChannels.get(channelId))) return true;
|
||||
if (remainingSeconds(channelId) > 0f) return true;
|
||||
for (Map.Entry<String, String> source : sourceChannels.entrySet()) {
|
||||
if (channelId.equals(source.getValue())
|
||||
&& (sourceKinds.get(source.getKey()) == SourceKind.PRESSURE
|
||||
|| sourceKinds.get(source.getKey()) == SourceKind.RELAY)
|
||||
&& Boolean.TRUE.equals(previousSourceActivity.get(source.getKey()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public float remainingSeconds(String channelId) {
|
||||
Float remaining = timedChannels.get(channelId);
|
||||
return remaining == null ? 0f : Math.max(0f, remaining);
|
||||
}
|
||||
|
||||
public List<Event> events() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public Diagnostics diagnostics() {
|
||||
int activeSignals = 0;
|
||||
for (String channelId : knownChannels) {
|
||||
if (signalActive(channelId)) activeSignals++;
|
||||
}
|
||||
int openDoors = 0;
|
||||
for (DoorController door : doors) {
|
||||
if (door.open()) openDoors++;
|
||||
}
|
||||
return new Diagnostics(updates, signalActivatedTransitions,
|
||||
signalDeactivatedTransitions, doorOpenedTransitions,
|
||||
doorClosedTransitions, activeSignals, openDoors);
|
||||
}
|
||||
|
||||
public State snapshot() {
|
||||
return new State(toggleChannels, timedChannels);
|
||||
}
|
||||
|
||||
public void restore(State state) {
|
||||
if (state == null) throw new IllegalArgumentException("state must not be null");
|
||||
restore(state.toggleSignals(), state.timedSignals());
|
||||
}
|
||||
|
||||
public void restore(State state, Map<String, Boolean> sourceActivity) {
|
||||
if (state == null) throw new IllegalArgumentException("state must not be null");
|
||||
restore(state.toggleSignals(), state.timedSignals(), sourceActivity);
|
||||
}
|
||||
|
||||
public void restore(Map<String, Boolean> toggles, Map<String, Float> timers) {
|
||||
restore(toggles, timers, Collections.emptyMap());
|
||||
}
|
||||
|
||||
public void restore(Map<String, Boolean> toggles, Map<String, Float> timers,
|
||||
Map<String, Boolean> sourceActivity) {
|
||||
if (toggles == null || timers == null) {
|
||||
throw new IllegalArgumentException("puzzle state maps must not be null");
|
||||
}
|
||||
if (sourceActivity == null) {
|
||||
throw new IllegalArgumentException("sourceActivity must not be null");
|
||||
}
|
||||
reset();
|
||||
toggleChannels.putAll(toggles);
|
||||
timedChannels.putAll(timers);
|
||||
previousSourceActivity.putAll(sourceActivity);
|
||||
knownChannels.addAll(toggles.keySet());
|
||||
knownChannels.addAll(timers.keySet());
|
||||
for (DoorController door : doors) door.update(this);
|
||||
events = Collections.emptyList();
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
toggleChannels.clear();
|
||||
timedChannels.clear();
|
||||
previousSourceActivity.clear();
|
||||
sourceKinds.clear();
|
||||
sourceChannels.clear();
|
||||
knownChannels.clear();
|
||||
for (DoorController door : doors) door.reset();
|
||||
events = Collections.emptyList();
|
||||
updates = 0L;
|
||||
signalActivatedTransitions = 0L;
|
||||
signalDeactivatedTransitions = 0L;
|
||||
doorOpenedTransitions = 0L;
|
||||
doorClosedTransitions = 0L;
|
||||
}
|
||||
|
||||
private static String requireId(String value, String name) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,329 @@
|
||||
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_OBJECTS = 64;
|
||||
private static final int MAX_PLATFORMS = 64;
|
||||
private static final int MAX_MAP_ENTRIES = 128;
|
||||
public static final int CURRENT_VERSION = 5;
|
||||
|
||||
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[] encodeLegacyV2(GameSessionSnapshot snapshot) {
|
||||
return encodeWithVersion(snapshot, 2);
|
||||
}
|
||||
|
||||
static byte[] encodeLegacyV3(GameSessionSnapshot snapshot) {
|
||||
return encodeWithVersion(snapshot, 3);
|
||||
}
|
||||
|
||||
static byte[] encodeLegacyV4(GameSessionSnapshot snapshot) {
|
||||
return encodeWithVersion(snapshot, 4);
|
||||
}
|
||||
|
||||
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 > LevelCatalog.all().size()) {
|
||||
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, version));
|
||||
if (version >= 4) {
|
||||
int objectCount = checkedCount(input.readInt(), MAX_OBJECTS);
|
||||
for (int i = 0; i < objectCount; i++) builder.addObject(readObject(input));
|
||||
}
|
||||
if (version >= 5) builder.collectibleFound(input.readBoolean());
|
||||
|
||||
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, version);
|
||||
}
|
||||
if (version >= 4) {
|
||||
output.writeInt(snapshot.objects().size());
|
||||
for (GameSessionSnapshot.ObjectData object : snapshot.objects()) {
|
||||
writeObject(output, object);
|
||||
}
|
||||
}
|
||||
if (version >= 5) output.writeBoolean(snapshot.collectibleFound());
|
||||
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,
|
||||
int version)
|
||||
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());
|
||||
if (version >= 3) {
|
||||
output.writeBoolean(body.crushed());
|
||||
output.writeBoolean(body.burned());
|
||||
}
|
||||
output.writeInt(body.supportIndex());
|
||||
output.writeUTF(body.deathType());
|
||||
}
|
||||
|
||||
private static GameSessionSnapshot.BodyData readBody(DataInputStream input, int version)
|
||||
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();
|
||||
boolean crushed = version >= 3 && input.readBoolean();
|
||||
boolean burned = version >= 3 && input.readBoolean();
|
||||
int support = input.readInt();
|
||||
String deathType = input.readUTF();
|
||||
if (deathType.length() > 24) throw new IllegalArgumentException("Death type too long");
|
||||
return new GameSessionSnapshot.BodyData(x, y, width, height,
|
||||
velocityX, velocityY, frozen, conductive, pinned, grounded,
|
||||
crushed, burned, support, deathType);
|
||||
}
|
||||
|
||||
private static void writeObject(DataOutputStream output,
|
||||
GameSessionSnapshot.ObjectData object) throws IOException {
|
||||
output.writeUTF(object.type().name());
|
||||
output.writeFloat(object.x());
|
||||
output.writeFloat(object.y());
|
||||
output.writeFloat(object.width());
|
||||
output.writeFloat(object.height());
|
||||
output.writeFloat(object.velocityX());
|
||||
output.writeFloat(object.velocityY());
|
||||
output.writeInt(object.mass());
|
||||
output.writeBoolean(object.grounded());
|
||||
output.writeInt(object.supportIndex());
|
||||
}
|
||||
|
||||
private static GameSessionSnapshot.ObjectData readObject(DataInputStream input)
|
||||
throws IOException {
|
||||
String typeName = input.readUTF();
|
||||
LevelDefinition.PhysicalObjectType type =
|
||||
LevelDefinition.PhysicalObjectType.valueOf(typeName);
|
||||
float x = finite(input.readFloat());
|
||||
float y = finite(input.readFloat());
|
||||
float width = finite(input.readFloat());
|
||||
float height = finite(input.readFloat());
|
||||
float velocityX = finite(input.readFloat());
|
||||
float velocityY = finite(input.readFloat());
|
||||
int mass = input.readInt();
|
||||
if (mass <= 0) throw new IllegalArgumentException("Object mass must be positive");
|
||||
boolean grounded = input.readBoolean();
|
||||
int supportIndex = input.readInt();
|
||||
return new GameSessionSnapshot.ObjectData(type, x, y, width, height,
|
||||
velocityX, velocityY, mass, grounded, supportIndex);
|
||||
}
|
||||
|
||||
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,252 @@
|
||||
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,
|
||||
TURN,
|
||||
JUMP,
|
||||
FALL,
|
||||
LAND,
|
||||
INTERACT,
|
||||
STUNNED,
|
||||
DEATH,
|
||||
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 motionFor(CharacterStateMachine.State state) {
|
||||
CharacterStateMachine.State safeState = state == null
|
||||
? CharacterStateMachine.State.IDLE : state;
|
||||
return Motion.valueOf(safeState.name());
|
||||
}
|
||||
|
||||
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 TURN:
|
||||
xs[Joint.LEFT_HAND.ordinal()] = 0.72f;
|
||||
xs[Joint.RIGHT_HAND.ordinal()] = 0.28f;
|
||||
ys[Joint.LEFT_HAND.ordinal()] = 0.55f;
|
||||
ys[Joint.RIGHT_HAND.ordinal()] = 0.55f;
|
||||
xs[Joint.LEFT_KNEE.ordinal()] = 0.48f;
|
||||
xs[Joint.RIGHT_KNEE.ordinal()] = 0.52f;
|
||||
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 LAND:
|
||||
ys[Joint.ROOT.ordinal()] = 0.91f;
|
||||
ys[Joint.HIPS.ordinal()] = 0.74f;
|
||||
ys[Joint.LEFT_KNEE.ordinal()] = 0.87f;
|
||||
ys[Joint.RIGHT_KNEE.ordinal()] = 0.87f;
|
||||
xs[Joint.LEFT_HAND.ordinal()] = 0.18f;
|
||||
xs[Joint.RIGHT_HAND.ordinal()] = 0.82f;
|
||||
break;
|
||||
case INTERACT:
|
||||
xs[Joint.RIGHT_HAND.ordinal()] = 0.98f;
|
||||
ys[Joint.RIGHT_HAND.ordinal()] = 0.47f;
|
||||
xs[Joint.LEFT_HAND.ordinal()] = 0.45f;
|
||||
ys[Joint.LEFT_HAND.ordinal()] = 0.54f;
|
||||
break;
|
||||
case STUNNED:
|
||||
xs[Joint.LEFT_HAND.ordinal()] = 0.36f;
|
||||
xs[Joint.RIGHT_HAND.ordinal()] = 0.64f;
|
||||
ys[Joint.LEFT_HAND.ordinal()] = 0.22f;
|
||||
ys[Joint.RIGHT_HAND.ordinal()] = 0.22f;
|
||||
xs[Joint.HEAD.ordinal()] += swing * 0.04f;
|
||||
break;
|
||||
case DEATH:
|
||||
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.DEATH
|
||||
&& 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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user