Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243da9185e | ||
|
|
f53e068cb1 | ||
|
|
4d8aa28f0a | ||
|
|
b31c2ea720 | ||
|
|
4869649b47 | ||
|
|
3de8d45039 | ||
|
|
259fcd316b | ||
|
|
19bb64edfb | ||
|
|
dc2ebcbf8c | ||
|
|
cc7f487cf7 | ||
|
|
cc3b5902cc | ||
|
|
62ee6877f9 | ||
|
|
0943caa3cc | ||
|
|
dcb7c2d497 | ||
|
|
3527ee5bd6 | ||
|
|
cde886ca96 |
@@ -32,3 +32,18 @@
|
||||
{"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."}}
|
||||
|
||||
+28
-28
@@ -1,29 +1,29 @@
|
||||
{"_type":"issue","id":"goon-game-level10-sliding-corpse","title":"Остановить скольжение тела на плите уровня 10","description":"Брошенное пилой тело уровня 10 может продолжать скользить по полу и съезжать с PRESSURE lift, из-за чего движущаяся платформа теряет сигнал и intended route становится нестабильным.","acceptance_criteria":"После доставки тело стабильно остаётся на PRESSURE lift без ручного тайминга; сигнал lift не пропадает при длительном ожидании; уровень 10 проходит normal и delayed-input маршруты по 3 раза без дополнительных смертей.","notes":"2026-08-18: focused RED showed firstCorpseX drift 791.4791 -\u003e 866.5033 in 750 ms. Grounded bodies now settle fully inside PRESSURE, stop, and pin. GREEN: position stable, lift powered after 6 s, exactly 1 death/body; level 10 delayed run and 3 consecutive mastery runs passed; final unfiltered matrix 16/16.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T08:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:53Z","started_at":"2026-08-18T08:40:02Z","closed_at":"2026-08-18T10:41:47Z","close_reason":"Acceptance met: delivered body remains on lift plate, signal stays active, normal 3x and delayed-input level 10 routes pass.","metadata":{"stages":2,"primary_stage":2},"labels":["bug","current","level10","physics","primary-stage-2","stage-2"],"dependencies":[{"issue_id":"goon-game-level10-sliding-corpse","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T11:40:01Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level6-freezer-overlap","title":"Исправить повторную смерть на frozen corpse уровня 6","description":"Frozen corpse уровня 6 фиксируется на y=776..880, а FREEZER остаётся y=750..880; игрок на теле занимает y=676..776 и пересекает hazard на 26 px, поэтому intended stepping-stone route вызывает немедленную вторую смерть.","acceptance_criteria":"Игрок может стоять и прыгнуть с предусмотренного frozen corpse без повторной FREEZE-смерти; вход в FREEZER с пола всё ещё убивает; intended route уровня 6 проходит три раза подряд production-input ботом.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:46:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:53Z","started_at":"2026-08-17T22:46:52Z","closed_at":"2026-08-18T01:30:37Z","close_reason":"FREEZER больше не убивает игрока на frozen stepping stone; floor lethality сохранена; уровень 6 проходит 3/3.","metadata":{"stages":2,"primary_stage":2},"labels":["bug","current","freeze","level6","primary-stage-2","stage-2"],"dependencies":[{"issue_id":"goon-game-level6-freezer-overlap","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:46:51Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level8-softlock","title":"Устранить softlock таймера уровня 8","description":"Если clock истекает после пересечения PIT, дверь закрывается, платформа останавливается, а переключатель остаётся слева: игрок может оказаться заперт справа до restart.","acceptance_criteria":"Бот воспроизводит expiry после PIT; игрок всегда имеет путь к restart-free recovery или таймер/геометрия исключают ловушку; сценарий проходит 3 раза подряд.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:40Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:54Z","started_at":"2026-08-17T22:33:08Z","closed_at":"2026-08-18T01:30:42Z","close_reason":"Expiry справа от PIT воспроизводился; recovery relay и баланс таймера проверены intended 3/3 и recovery 3/3.","metadata":{"stages":2,"primary_stage":2},"labels":["bug","current","level8","primary-stage-2","softlock","stage-2"],"dependencies":[{"issue_id":"goon-game-level8-softlock","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:09:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-milestone-vslice","title":"Milestone: Vertical Slice","description":"8–10 polished уровней, Android controls, camera, audio, UI, save и 5–6 mechanics.","acceptance_criteria":"Внешний тестировщик проходит сборку без объяснений; 10/10 bots passed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:09Z","started_at":"2026-08-17T21:41:29Z","metadata":{"stages":5,"primary_stage":5},"labels":["current","gate","milestone","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-android-lifecycle","type":"blocks","created_at":"2026-08-18T00:44:22Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-art-vslice","type":"blocks","created_at":"2026-08-18T00:44:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-audio-vslice","type":"blocks","created_at":"2026-08-18T00:44:19Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-controls-settings","type":"blocks","created_at":"2026-08-18T00:44:17Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:55Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-human-playtest","type":"blocks","created_at":"2026-08-18T00:44:16Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-menus-localization","type":"blocks","created_at":"2026-08-18T00:44:18Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:44:23Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:44:15Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-save-versioning","type":"blocks","created_at":"2026-08-18T00:44:21Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":9,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-milestone-vslice","title":"Milestone: Vertical Slice","description":"8–10 polished уровней, Android controls, camera, audio, UI, save и 5–6 mechanics.","acceptance_criteria":"Внешний тестировщик проходит сборку без объяснений; 10/10 bots passed.","notes":"2026-08-19 engineering RC complete: art/audio/camera/save/lifecycle/determinism/performance tasks closed; clean 72 JVM tests and exact Android 16 35/35 in 493.521s; APK format/signature/install/cold-start verified. Formal milestone remains in_progress solely for goon-game-human-playtest and the linked external comprehension portion of goon-game-level-balance.\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; наблюдения и решения зафиксированы.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:09Z","metadata":{"stages":5,"primary_stage":5},"labels":["primary-stage-5","qa","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:51Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-level-balance","type":"blocks","created_at":"2026-08-18T00:41:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-regression-10","title":"Единый playability regression 10/10","description":"Запускать всех ботов одной Gradle-командой и публиковать краткий отчёт по уровням.","acceptance_criteria":"Три полных последовательных прогона дают 10/10 passed менее чем за 5 минут каждый.","notes":"2026-08-18: one final integrated Android 16 run is green: 14/14 methods, 35 gameplay executions plus lifecycle, Gradle wall time 5:59. Correctness is proven, but acceptance remains open because the \u003c5:00 target is missed by 59s and three full matrix repetitions are not yet recorded.\n2026-08-18 final Stage 2 candidate: one exact unfiltered Android 16 run passed 16/16 methods, 36 gameplay executions plus lifecycle/restart in 479.619 s. Correctness gate is green; historical \u003c5:00 and three full exact-candidate repetitions remain open as runner-performance evidence.\n2026-08-18 exact versionCode 5 clean-build matrix: OK 16/16, 36 gameplay executions plus lifecycle/restart, 572.496 s, no retries. APK installed over previous build before the run.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":480,"created_at":"2026-08-17T21:40:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:16Z","metadata":{"stages":"1,5","primary_stage":5},"labels":["current","gate","primary-stage-5","qa","stage-1","stage-5"],"dependencies":[{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-1-4","type":"blocks","created_at":"2026-08-18T00:41:33Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-6-10","type":"blocks","created_at":"2026-08-18T00:41:34Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-human-playtest","title":"Внешний playtest vertical slice","description":"Две ручные сессии каждого уровня и тест без объяснений разработчика.","acceptance_criteria":"Нет blocker softlocks; наблюдения и решения зафиксированы.","notes":"2026-08-19 immutable candidate prepared: 0.8.0-stage5-vslice-debug, versionCode 9, APK SHA-256 8bb970c7385a48d8002907382067a5f8f87554a33ad7aaace9aa593194c8fed5. Protocol and required per-session record are in docs/STAGE5_HUMAN_PLAYTEST.md. Zero of 20 external sessions may be inferred from bot/developer testing.\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 восстановления.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:26Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:10Z","started_at":"2026-08-17T21:52:46Z","metadata":{"stages":5,"primary_stage":5},"labels":["android","current","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-android-lifecycle","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-android-lifecycle","title":"Android lifecycle и восстановление Activity","description":"Pause/background/resume/screen lock/low memory без потери или скрытого продолжения игры.","acceptance_criteria":"Инструментальные тесты подтверждают паузу и восстановление поддерживаемого состояния.","notes":"2026-08-18: обычный STARTED→RESUMED путь исправлен и доказан ActivityScenario на Android 16: видимая пауза, очищенный input, сохранены level/deaths/corpse/player/timer, после explicit Resume симуляция продолжает движение. Connected GREEN 1/1. Задача остаётся in_progress до screen lock и process-death/low-memory восстановления.\nStage 5 RC: background/resume clears held input and shows visible pause; Activity recreation restores level/player/bodies/timers/platform signals; focused and full Android gates GREEN. Broader API/device/low-memory matrix remains separately owned by Stage 7.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:26Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:01Z","started_at":"2026-08-17T21:52:46Z","closed_at":"2026-08-18T23:02:01Z","close_reason":"Supported lifecycle and Activity/process-like recreation state restore are proven for the Vertical Slice.","metadata":{"stages":5,"primary_stage":5},"labels":["android","current","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-android-lifecycle","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-android-apk","title":"Собираемый и устанавливаемый Android APK","description":"Debug APK versionCode 3 проверен на Android 16, ZIP и подписи валидны.","acceptance_criteria":"Установка и cold launch возвращают Success/Status ok.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:24Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:02Z","closed_at":"2026-08-17T21:41:23Z","close_reason":"APK versionCode 3 собран, подписан, установлен и запущен.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-android-apk","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level-balance","title":"Исправить стабильность и баланс уровней 1–10","description":"Устранить softlocks, нестабильные тела и чрезмерно точные прыжки, найденные ботами и ручным тестированием.","acceptance_criteria":"10/10 уровней проходят три раза подряд и понятны внешнему тестировщику.","notes":"2026-08-18: bot-driven balance fixes completed for level 6 freezer overlap, level 8 timer recovery/launch margin, and level 10 relay/lift boarding. Intended routes 1–10 pass 3x in one integrated matrix. Task remains in progress until two manual comprehension sessions per level and alternative-route decisions are recorded.\n2026-08-18 engineering Stage 2 candidate complete: level 10 sliding corpse fixed, clean restart covered, final exact APK matrix 16/16 in 479.619 s. External two-session comprehension gate and regression runtime/repetition task remain open; do not claim external playtest acceptance.\n2026-08-18 exact versionCode 5 full matrix also GREEN 16/16 in 572.496 s. Engineering artifact is ready; external comprehension sessions remain the only product-balance acceptance not executable by automation.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:20Z","started_at":"2026-08-18T01:31:03Z","metadata":{"stages":"2,5","primary_stage":5},"labels":["balance","current","levels","primary-stage-5","stage-2","stage-5"],"dependencies":[{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level-route-risks","type":"blocks","created_at":"2026-08-18T01:10:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level10-sliding-corpse","type":"blocks","created_at":"2026-08-18T11:40:08Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level8-softlock","type":"blocks","created_at":"2026-08-18T01:10:14Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-solution-graphs","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level-balance","title":"Исправить стабильность и баланс уровней 1–10","description":"Устранить softlocks, нестабильные тела и чрезмерно точные прыжки, найденные ботами и ручным тестированием.","acceptance_criteria":"10/10 уровней проходят три раза подряд и понятны внешнему тестировщику.","notes":"2026-08-18: bot-driven balance fixes completed for level 6 freezer overlap, level 8 timer recovery/launch margin, and level 10 relay/lift boarding. Intended routes 1–10 pass 3x in one integrated matrix. Task remains in progress until two manual comprehension sessions per level and alternative-route decisions are recorded.\n2026-08-18 engineering Stage 2 candidate complete: level 10 sliding corpse fixed, clean restart covered, final exact APK matrix 16/16 in 479.619 s. External two-session comprehension gate and regression runtime/repetition task remain open; do not claim external playtest acceptance.\n2026-08-18 exact versionCode 5 full matrix also GREEN 16/16 in 572.496 s. Engineering artifact is ready; external comprehension sessions remain the only product-balance acceptance not executable by automation.\n2026-08-19 Stage 5 RC: exact unfiltered Android 16 matrix GREEN 35/35 in 493.521s; deterministic 10-level tapes replay 3x with matching signatures; level 8 recovery and level 10 body stability/delayed traversal GREEN. Engineering balance is complete. Task remains in_progress only because external comprehension requires 20 independent sessions; see docs/STAGE5_HUMAN_PLAYTEST.md.\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 каждого уровня дают одинаковый итог и не расходятся по ключевым состояниям.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:33Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:10Z","metadata":{"stages":5,"primary_stage":5},"labels":["current","physics","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-physics-determinism","depends_on_id":"goon-game-e03","type":"parent-child","created_at":"2026-08-18T00:43:08Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-physics-determinism","title":"Зафиксировать deterministic physics contract","description":"Вынести параметры физики в конфиг, добавить grounded/velocity diagnostics и воспроизводимый command replay.","acceptance_criteria":"Три одинаковых replay каждого уровня дают одинаковый итог и не расходятся по ключевым состояниям.","notes":"Stage 5 RC: PhysicsConfig v1 and fixed 1/60 step; exact command tapes for levels 1-10 replay three sequential passes with identical state signatures and death/body counts, each pass guarded under five minutes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:33Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:08Z","closed_at":"2026-08-18T23:02:08Z","close_reason":"Three identical replays for every level produce matching states and outcomes.","metadata":{"stages":5,"primary_stage":5},"labels":["current","physics","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-physics-determinism","depends_on_id":"goon-game-e03","type":"parent-child","created_at":"2026-08-18T00:43:08Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-physics-basic","title":"Базовая предсказуемая Canvas-физика","description":"Коллизии персонажа, тел, платформ и дверей работают в текущем vertical slice.","acceptance_criteria":"Unit-тесты и бот уровня 5 проходят.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:31Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:03Z","closed_at":"2026-08-17T21:41:15Z","close_reason":"Базовая физика присутствует; production determinism остаётся отдельной задачей.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-physics-basic","depends_on_id":"goon-game-e03","type":"parent-child","created_at":"2026-08-18T00:43:07Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-death-core","title":"Death, corpse и respawn без сброса уровня","description":"Работающая основа смерти, сохранения тела и появления следующего персонажа.","acceptance_criteria":"Проверено на уровне 5 и smoke-тестах APK.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:22Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:03Z","closed_at":"2026-08-17T21:41:14Z","close_reason":"Реализовано и проверено ботом уровня 5.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-death-core","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:03Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e16","title":"EPIC-16 Milestones \u0026 Release","description":"Prototype → Vertical Slice → Production → Alpha → Beta → Release Candidate. Разделы 53–59.","acceptance_criteria":"Есть подписанный store build без blocker и critical progression bugs.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:16Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:04:08Z","started_at":"2026-08-17T21:41:29Z","metadata":{"stages":"0,5,6,8,9,10","primary_stage":10},"labels":["epic","milestone","primary-stage-10","release","stage-0","stage-10","stage-5","stage-6","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e16","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:43:00Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e15","title":"EPIC-15 QA","description":"Unit, integration, playability bots, softlock и mobile QA. Разделы 48–51, 63–64.","acceptance_criteria":"Все уровни автоматически и вручную проверены по обязательной матрице.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:14Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:04:02Z","started_at":"2026-08-17T21:41:28Z","metadata":{"stages":"0,1,2,3,4,5,6,7,8,9,10","primary_stage":10},"labels":["current","epic","primary-stage-10","qa","stage-0","stage-1","stage-10","stage-2","stage-3","stage-4","stage-5","stage-6","stage-7","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e15","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:58Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e12","title":"EPIC-12 Android","description":"Lifecycle, устройства, aspect ratios, graphics profiles и сборки. Разделы 41, 44–46, 49.","acceptance_criteria":"Release build стабильно работает на целевой Android-матрице.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:08Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:47Z","metadata":{"stages":"0,3,5,7","primary_stage":7},"labels":["android","epic","primary-stage-7","stage-0","stage-3","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e12","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:55Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e07","title":"EPIC-07 Levels","description":"Solution graphs, greybox, прогрессия механик и производство 55–70 уровней. Разделы 19–25, 52–53, 66.","acceptance_criteria":"Каждый уровень имеет доказанное решение, DoD и место в content matrix.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:57Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:25Z","metadata":{"stages":"0,2,4,6","primary_stage":6},"labels":["content","epic","levels","primary-stage-6","stage-0","stage-2","stage-4","stage-6"],"dependencies":[{"issue_id":"goon-game-e07","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:49Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e03","title":"EPIC-03 Physics","description":"Предсказуемая гибридная физика и бюджеты активных объектов. Разделы 15, 41–43, 65.","acceptance_criteria":"Одинаковая последовательность ввода воспроизводимо приводит к одинаковому состоянию головоломки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:07Z","metadata":{"stages":"0,5,7","primary_stage":5},"labels":["epic","physics","primary-stage-5","stage-0","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e03","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e02","title":"EPIC-02 Death \u0026 Corpse","description":"Смерть как игровая механика, состояния тела, CorpseManager и respawn. Разделы 4–8.","acceptance_criteria":"Все типы смерти создают предсказуемое и пригодное для головоломки состояние тела.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:03Z","metadata":{"stages":"0,6,7","primary_stage":6},"labels":["corpse","death","epic","primary-stage-6","stage-0","stage-6","stage-7"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-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}
|
||||
@@ -35,44 +35,44 @@
|
||||
{"_type":"issue","id":"goon-game-mobile-qa","title":"Mobile QA matrix","description":"GPU, API, RAM, refresh rate, thermal, background, battery и controller проверки.","acceptance_criteria":"Матрица из раздела 49 заполнена и приложена к release evidence.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:34Z","metadata":{"stages":7,"primary_stage":7},"labels":["android","primary-stage-7","qa","stage-7"],"dependencies":[{"issue_id":"goon-game-mobile-qa","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:41:39Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-mobile-qa","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:53Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-crash-reporting","title":"Crash/ANR reporting без персональных данных","description":"Подключить отчёты ошибок с build/level context и политикой приватности.","acceptance_criteria":"Тестовая ошибка видна в системе; payload не содержит пользовательских секретов.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:38Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:34Z","metadata":{"stages":7,"primary_stage":7},"labels":["analytics","android","primary-stage-7","stage-7"],"dependencies":[{"issue_id":"goon-game-crash-reporting","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-corpse-sleep-pooling","title":"Sleeping corpses и object pooling","description":"Усыплять остановившиеся тела и pool-ить FX/audio/projectiles без изменения головоломок.","acceptance_criteria":"Нет GC spikes; спящие тела корректно пробуждаются внешним воздействием.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:34Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:34Z","metadata":{"stages":7,"primary_stage":7},"labels":["corpse","performance","primary-stage-7","stage-7"],"dependencies":[{"issue_id":"goon-game-corpse-sleep-pooling","depends_on_id":"goon-game-corpse-manager","type":"blocks","created_at":"2026-08-18T00:41:39Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-sleep-pooling","depends_on_id":"goon-game-e13","type":"parent-child","created_at":"2026-08-18T00:43:41Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-sleep-pooling","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:41:40Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-perf-budgets","title":"Зафиксировать performance/physics budgets","description":"Определить бюджеты frame time, bodies, collision pairs, particles, draw calls и memory.","acceptance_criteria":"Метрики собираются воспроизводимо, целевые устройства держат согласованный FPS.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:32Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:10Z","metadata":{"stages":5,"primary_stage":5},"labels":["performance","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-perf-budgets","depends_on_id":"goon-game-e13","type":"parent-child","created_at":"2026-08-18T00:43:39Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-perf-budgets","title":"Зафиксировать performance/physics budgets","description":"Определить бюджеты frame time, bodies, collision pairs, particles, draw calls и memory.","acceptance_criteria":"Метрики собираются воспроизводимо, целевые устройства держат согласованный FPS.","notes":"Stage 5 RC: published 34ms frame, 12ms update, 20ms draw, 16 bodies, 2500 collision checks, 2200 primitives, 128MiB budgets. Host-GPU Android 16 gate GREEN; SwiftShader p95 41.61ms is transparently documented as non-target.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:32Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:15Z","closed_at":"2026-08-18T23:02:15Z","close_reason":"Reproducible target-device performance and complexity budgets are implemented and pass.","metadata":{"stages":5,"primary_stage":5},"labels":["performance","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-perf-budgets","depends_on_id":"goon-game-e13","type":"parent-child","created_at":"2026-08-18T00:43:39Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-release-build","title":"Release signing, APK и AAB","description":"Настроить release application ID, защищённый keystore и Google Play Internal Testing bundle.","acceptance_criteria":"Подписанные APK/AAB устанавливаются и проходят smoke-тест; секреты не в репозитории.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:30Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:35Z","metadata":{"stages":7,"primary_stage":7},"labels":["android","primary-stage-7","release","stage-7"],"dependencies":[{"issue_id":"goon-game-release-build","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:38Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-device-matrix","title":"Прогон Android 8–16 и форм-факторов","description":"Проверить API, RAM 2–4 GB, aspect ratios, refresh rates и interruptions.","acceptance_criteria":"Матрица устройств заполнена, blocker/critical дефекты закрыты.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:28Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:35Z","metadata":{"stages":7,"primary_stage":7},"labels":["android","primary-stage-7","qa","stage-7"],"dependencies":[{"issue_id":"goon-game-device-matrix","depends_on_id":"goon-game-android-lifecycle","type":"blocks","created_at":"2026-08-18T00:41:37Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-device-matrix","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:37Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-device-matrix","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:41:38Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-art-vslice","title":"Финальный art/animation pass уровней 1–10","description":"Заменить prototype Canvas-образы финальными персонажами, окружением, UI и анимациями.","acceptance_criteria":"Все игровые состояния читаются визуально; vertical slice готов внешнему тестировщику.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":4800,"created_at":"2026-08-17T21:40:19Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:11Z","metadata":{"stages":5,"primary_stage":5},"labels":["art","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-art-vslice","depends_on_id":"goon-game-art-bible","type":"blocks","created_at":"2026-08-18T00:41:37Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-art-vslice","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:32Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-art-bible","title":"Утвердить самостоятельный Art Bible","description":"Зафиксировать stylized fantasy slapstick, требования к ассетам и границы относительно коммерческого референса.","acceptance_criteria":"Art Bible согласован и не требует копирования защищённого контента.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:17Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:11Z","metadata":{"stages":5,"primary_stage":5},"labels":["art","design","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-art-bible","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:31Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-audio-vslice","title":"Audio pass vertical slice","description":"Добавить UI, movement, mechanisms, deaths, voice variations и музыку без немедленных повторов.","acceptance_criteria":"Все 10 уровней имеют настроенный звук и отдельные volume controls.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:40:15Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:11Z","metadata":{"stages":5,"primary_stage":5},"labels":["audio","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-audio-vslice","depends_on_id":"goon-game-e10","type":"parent-child","created_at":"2026-08-18T00:43:30Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-save-versioning","title":"Версионирование и миграции сохранений","description":"Добавить saveVersion, миграции и тест обновления поверх предыдущего APK.","acceptance_criteria":"Старое сохранение мигрирует без потери завершённых уровней.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:11Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:12Z","metadata":{"stages":5,"primary_stage":5},"labels":["primary-stage-5","save","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-save-versioning","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:27Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-art-vslice","title":"Финальный art/animation pass уровней 1–10","description":"Заменить prototype Canvas-образы финальными персонажами, окружением, UI и анимациями.","acceptance_criteria":"Все игровые состояния читаются визуально; vertical slice готов внешнему тестировщику.","notes":"Stage 5 RC: original Clockwork Catacombs rig, 3 skins, readable alive/run/jump/fall/dead/frozen/conductive/pinned/respawn states; visual smoke and exact 35/35 Android gate GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":4800,"created_at":"2026-08-17T21:40:19Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:31Z","started_at":"2026-08-18T21:41:06Z","closed_at":"2026-08-18T23:01:31Z","close_reason":"Original articulated visual pass is implemented for all ten levels and verified in the final candidate.","metadata":{"stages":5,"primary_stage":5},"labels":["art","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-art-vslice","depends_on_id":"goon-game-art-bible","type":"blocks","created_at":"2026-08-18T00:41:37Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-art-vslice","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:32Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-art-bible","title":"Утвердить самостоятельный Art Bible","description":"Зафиксировать stylized fantasy slapstick, требования к ассетам и границы относительно коммерческого референса.","acceptance_criteria":"Art Bible согласован и не требует копирования защищённого контента.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:17Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:41:05Z","started_at":"2026-08-18T21:40:21Z","closed_at":"2026-08-18T21:41:05Z","close_reason":"Art Bible approved in docs/STAGE5_ART_BIBLE.md: original Clockwork Catacombs direction, articulated skin-ready rig, state readability, IP boundaries and performance constraints.","metadata":{"stages":5,"primary_stage":5},"labels":["art","design","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-art-bible","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:31Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-audio-vslice","title":"Audio pass vertical slice","description":"Добавить UI, movement, mechanisms, deaths, voice variations и музыку без немедленных повторов.","acceptance_criteria":"Все 10 уровней имеют настроенный звук и отдельные volume controls.","notes":"Stage 5 RC: procedural music/effects/voice variants, no immediate repeats, independent persisted Music/Effects/Voice controls; unit and Android persistence gates GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:40:15Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:39Z","closed_at":"2026-08-18T23:01:39Z","close_reason":"All ten levels use the completed procedural audio pass with independent persistent controls.","metadata":{"stages":5,"primary_stage":5},"labels":["audio","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-audio-vslice","depends_on_id":"goon-game-e10","type":"parent-child","created_at":"2026-08-18T00:43:30Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-save-versioning","title":"Версионирование и миграции сохранений","description":"Добавить saveVersion, миграции и тест обновления поверх предыдущего APK.","acceptance_criteria":"Старое сохранение мигрирует без потери завершённых уровней.","notes":"Stage 5 RC: saveVersion 2, safe v1 migration preserving completion, full active puzzle Bundle snapshot and visible-paused restore; GameSessionRestoreTest GREEN in full gate.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:11Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:55Z","closed_at":"2026-08-18T23:01:55Z","close_reason":"Legacy progress migrates without loss and supported active sessions restore safely.","metadata":{"stages":5,"primary_stage":5},"labels":["primary-stage-5","save","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-save-versioning","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:27Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-save-basic","title":"Базовое сохранение прогресса","description":"Сохраняются unlock, best time и минимальное число жертв.","acceptance_criteria":"Прогресс переживает перезапуск приложения.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:09Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:22Z","close_reason":"Локальный прогресс и лучшие результаты сохраняются.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-save-basic","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-accessibility","title":"Accessibility pass","description":"Проверить контраст, масштаб текста, safe areas, режим без вибрации и не-звуковые сигналы.","acceptance_criteria":"Критическая информация доступна визуально, элементы не перекрываются.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:06Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:00Z","started_at":"2026-08-18T13:09:20Z","closed_at":"2026-08-18T16:26:41Z","close_reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10.","metadata":{"stages":3,"primary_stage":3},"labels":["accessibility","primary-stage-3","stage-3","ui"],"dependencies":[{"issue_id":"goon-game-accessibility","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:25Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-menus-localization","title":"Меню, результаты и RU/EN локализация","description":"Добавить main/world/level select, settings, statistics, credits и полноценный result screen.","acceptance_criteria":"Все строки локализованы; игра проходится без объяснений разработчика.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:04Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:00Z","closed_at":"2026-08-18T16:26:43Z","close_reason":"Persistent RU/EN, onboarding and full result flow integrated and verified by final 30/30 gate. External human comprehension playtest remains tracked separately.","metadata":{"stages":3,"primary_stage":3},"labels":["localization","primary-stage-3","stage-3","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-menus-localization","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-controls-settings","title":"Настройки размера, opacity и вибрации controls","description":"Дать игроку настройку touch controls и исключить случайные повторные прыжки.","acceptance_criteria":"Настройки сохраняются и проверены на 16:9, 20:9 и планшете.","notes":"2026-08-18 Stage 3 controls slice complete. RED/GREEN unit contracts cover size/opacity/haptics presets. Production Settings screen changes values through real taps; SharedPreferences survives Activity recreation. Large preset expands real hit target, held jump does not retrigger after landing. Device GREEN: 20:9 class 3/3, logical 16:9 persistence 1/1, tablet-sized 1/1; lifecycle/restart/L5/L10 regressions 4/4; clean 20 JVM tests, lint and app/test APK build.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:01Z","started_at":"2026-08-18T11:28:25Z","closed_at":"2026-08-18T11:43:04Z","close_reason":"Acceptance met: size, opacity and vibration settings persist; real touch targets and held-jump edge are tested; UI contract passes 16:9, 20:9 and tablet-sized displays.","metadata":{"stages":3,"primary_stage":3},"labels":["current","primary-stage-3","stage-3","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-controls-settings","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:23Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-ui-vslice","title":"Базовый HUD и multitouch controls","description":"В APK есть направления, jump, restart, pause, deaths и timer.","acceptance_criteria":"Управление работает одновременно двумя касаниями.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:21Z","close_reason":"Базовый HUD и multitouch controls реализованы.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-ui-vslice","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:22Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-camera-polish","title":"Доработать production camera controller","description":"Добавить vertical damping, room transitions, focus target и управляемый shake.","acceptance_criteria":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:50Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:12Z","metadata":{"stages":5,"primary_stage":5},"labels":["camera","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-camera-polish","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:16Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-camera-polish","title":"Доработать production camera controller","description":"Добавить vertical damping, room transitions, focus target и управляемый shake.","acceptance_criteria":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","notes":"Stage 5 RC: bounded horizontal/vertical damping, mechanism focus, impact shake, reduced-motion persistence; camera excluded from physics signatures; 35/35 Android gate GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:50Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:45Z","closed_at":"2026-08-18T23:01:45Z","close_reason":"Production camera behavior and reduced-motion acceptance are implemented and regression-tested.","metadata":{"stages":5,"primary_stage":5},"labels":["camera","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-camera-polish","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:16Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-camera-basic","title":"Базовая камера с look-ahead и bounds","description":"В текущем runtime реализовано сглаженное горизонтальное слежение.","acceptance_criteria":"Камера работает во всех 10 уровнях.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:18Z","close_reason":"Базовое сглаженное слежение реализовано.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-camera-basic","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:15Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-puzzle-events","title":"Выделить event-driven PuzzleSystem","description":"Заменить прямую проверку сигналов универсальными каналами событий и явными состояниями DoorController.","acceptance_criteria":"Кнопки, двери и платформы связываются данными; integration-тесты проходят.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","primary-stage-6","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-puzzle-events","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:13Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-puzzle-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":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:28Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","corpse","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-manager","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:04Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-character-states","title":"Завершить state machine персонажа","description":"Добавить явные Interact, Stunned, Turn, Land и animation-state переходы; вынести coyote/input buffer в конфиг.","acceptance_criteria":"Все переходы детерминированы, визуально различимы и имеют тесты.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:20Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:28Z","metadata":{"stages":6,"primary_stage":6},"labels":["character","future","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-character-states","depends_on_id":"goon-game-e01","type":"parent-child","created_at":"2026-08-18T00:43:02Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-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":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:01Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:33Z","metadata":{"stages":"0,5,6","primary_stage":6},"labels":["epic","primary-stage-6","progression","save","stage-0","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e09","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:51Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-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":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:20Z","metadata":{"stages":"0,5","primary_stage":5},"labels":["camera","epic","primary-stage-5","stage-0","stage-5"],"dependencies":[{"issue_id":"goon-game-e06","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e05","title":"EPIC-05 Puzzle Objects","description":"Кнопки, двери, платформы, весовые объекты и событийные сигналы. Разделы 11–14, 37.","acceptance_criteria":"Механизмы не связаны жёсткими ссылками и корректно работают с живым персонажем и телами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:53Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:15Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","primary-stage-6","puzzle","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e05","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e04","title":"EPIC-04 Hazards","description":"Каталог опасностей и универсальный контракт источника смерти. Разделы 5, 10, 38.","acceptance_criteria":"Опасности data-driven, задокументированы и проходят Android/QA проверки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:11Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","hazards","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e04","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:45Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e01","title":"EPIC-01 Character","description":"Персонаж, state machine, движение и platforming. Разделы 3, 17, 28, 36.","acceptance_criteria":"Персонаж соответствует утверждённому набору состояний и стабильно управляется на Android.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:56Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["character","epic","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e01","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-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.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","progression","stage-6"],"dependencies":[{"issue_id":"goon-game-challenges","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["hazards","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-hazards-full","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:10Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-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 без раздражающих повторов.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:03Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:37Z","metadata":{"stages":5,"primary_stage":5},"labels":["audio","epic","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-e10","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:52Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e10","title":"EPIC-10 Audio","description":"Sound design, вариативность реакций и музыкальные loops. Разделы 29–30.","acceptance_criteria":"Vertical slice имеет полный audio pass без раздражающих повторов.","notes":"Stage 5 audio acceptance complete: procedural music/effects/voice variation, lifecycle safety and independent persisted controls; the EPIC child is closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:03Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:09Z","closed_at":"2026-08-18T23:03:09Z","close_reason":"Vertical Slice has a complete non-repeating audio pass.","metadata":{"stages":5,"primary_stage":5},"labels":["audio","epic","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-e10","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:52Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-milestone-rc","title":"Milestone: Release Candidate","description":"Финальный gate раздела 59.","acceptance_criteria":"0 blocker, 0 critical progression bugs, all levels completable, save migration/lifecycle/store signing/analytics/crash reporting verified.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":3,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:11Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:47Z","metadata":{"stages":10,"primary_stage":10},"labels":["milestone","primary-stage-10","release-candidate","stage-10"],"dependencies":[{"issue_id":"goon-game-milestone-rc","depends_on_id":"goon-game-analytics-contract","type":"blocks","created_at":"2026-08-18T00:44:32Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-rc","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:44:00Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-rc","depends_on_id":"goon-game-milestone-beta","type":"blocks","created_at":"2026-08-18T00:44:30Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-rc","depends_on_id":"goon-game-release-build","type":"blocks","created_at":"2026-08-18T00:44:31Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-milestone-beta","title":"Milestone: Beta","description":"Feature freeze, зафиксированные уровни, bug fixing, optimization и localization QA.","acceptance_criteria":"Нет новых механик; beta QA matrix зелёная.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":3,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:06Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:43Z","metadata":{"stages":9,"primary_stage":9},"labels":["beta","milestone","primary-stage-9","stage-9"],"dependencies":[{"issue_id":"goon-game-milestone-beta","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:58Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-beta","depends_on_id":"goon-game-milestone-alpha","type":"blocks","created_at":"2026-08-18T00:44:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
|
||||
@@ -8,12 +8,19 @@ Project documents:
|
||||
- [`docs/CURRENT_ROADMAP.md`](docs/CURRENT_ROADMAP.md) — execution roadmap for the existing vertical slice;
|
||||
- [`docs/LEVEL_SOLUTION_GRAPHS.md`](docs/LEVEL_SOLUTION_GRAPHS.md) — source-backed intended solution graph for every current level;
|
||||
- [`docs/STAGE4_CONTENT_FORMAT.md`](docs/STAGE4_CONTENT_FORMAT.md) — versioned JSON contract, validator, preview, and bot-trace format;
|
||||
- [`docs/STAGE5_ART_BIBLE.md`](docs/STAGE5_ART_BIBLE.md) — original visual language and articulated volunteer contract;
|
||||
- [`docs/STAGE5_PERFORMANCE_BUDGETS.md`](docs/STAGE5_PERFORMANCE_BUDGETS.md) — physics, replay, frame, memory, and complexity ceilings;
|
||||
- [`docs/STAGE5_QA_REPORT.md`](docs/STAGE5_QA_REPORT.md) — exact Vertical Slice candidate evidence;
|
||||
- [`docs/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;
|
||||
@@ -24,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
|
||||
|
||||
@@ -36,7 +51,8 @@ Death does not reset the level. A new volunteer respawns after a short delay whi
|
||||
- `II` pauses the game and exposes level selection.
|
||||
- `Settings` on the level-select screen cycles three control sizes, three
|
||||
opacity presets, vibration on/off, Standard/Large text, high contrast, and
|
||||
English/Russian. Changes persist across app restarts without resetting progress.
|
||||
English/Russian, reduced motion, and independent Music/Effects/Voice volumes.
|
||||
Changes persist across app restarts without resetting progress.
|
||||
|
||||
The first level includes a three-step, non-blocking tutorial for movement,
|
||||
multitouch jumping, and using retained bodies. Completion shows the current run,
|
||||
@@ -67,7 +83,7 @@ Previews are written to `level-format/build/level-previews`. Invalid geometry,
|
||||
versions, moving-platform destinations, switch parameters, or signal references
|
||||
fail the build with a JSON path diagnostic.
|
||||
|
||||
All ten levels have device-level acceptance bots. They drive the production
|
||||
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
|
||||
@@ -91,6 +107,57 @@ in one process (`OK (30 tests)`, 1,096.448 seconds). Acceptance bots now attach
|
||||
versioned deterministic input/checkpoint trace and SHA-256 trace ID to their
|
||||
result diagnostics.
|
||||
|
||||
Stage 5 replaces the prototype knight with an original articulated clockwork
|
||||
volunteer rig and three data-driven skins, adds procedural audio, camera focus and
|
||||
shake with reduced motion, save version 2 and active-session restoration. Its exact
|
||||
Android 16 candidate passed all 35 instrumentation methods in one unfiltered run
|
||||
(`493.521 s`) plus 72 JVM tests. Deterministic tapes replay all ten levels three
|
||||
times with identical state signatures. 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
|
||||
|
||||
+3
-2
@@ -10,8 +10,8 @@ android {
|
||||
applicationId 'com.projectsacrifice.game'
|
||||
minSdk 23
|
||||
targetSdk 34
|
||||
versionCode 8
|
||||
versionName '0.7.0-stage4-content'
|
||||
versionCode 9
|
||||
versionName '0.8.0-stage5-vslice'
|
||||
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
@@ -47,4 +47,5 @@ dependencies {
|
||||
|
||||
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,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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+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,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();
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ final class AccessibilityScreenDescription {
|
||||
return strings.settingsDescription(controls, accessibility);
|
||||
}
|
||||
|
||||
static String settings(GameStrings strings, ControlSettings controls,
|
||||
AccessibilitySettings accessibility, AudioSettings audio) {
|
||||
return strings.settingsDescription(controls, accessibility, audio);
|
||||
}
|
||||
|
||||
static String playing(GameStrings strings, LevelDefinition level, int deaths,
|
||||
String elapsed, String criticalState, boolean paused) {
|
||||
return strings.playingDescription(level.number, deaths, elapsed, criticalState, paused);
|
||||
|
||||
@@ -16,21 +16,29 @@ public final class AccessibilitySettings {
|
||||
|
||||
private final TextSize textSize;
|
||||
private final boolean highContrastEnabled;
|
||||
private final boolean reducedMotionEnabled;
|
||||
|
||||
private AccessibilitySettings(TextSize textSize, boolean highContrastEnabled) {
|
||||
private AccessibilitySettings(TextSize textSize, boolean highContrastEnabled,
|
||||
boolean reducedMotionEnabled) {
|
||||
this.textSize = textSize;
|
||||
this.highContrastEnabled = highContrastEnabled;
|
||||
this.reducedMotionEnabled = reducedMotionEnabled;
|
||||
}
|
||||
|
||||
public static AccessibilitySettings defaults() {
|
||||
return new AccessibilitySettings(TextSize.STANDARD, false);
|
||||
return new AccessibilitySettings(TextSize.STANDARD, false, false);
|
||||
}
|
||||
|
||||
static AccessibilitySettings restore(int textSizeIndex, boolean highContrastEnabled) {
|
||||
return restore(textSizeIndex, highContrastEnabled, false);
|
||||
}
|
||||
|
||||
static AccessibilitySettings restore(int textSizeIndex, boolean highContrastEnabled,
|
||||
boolean reducedMotionEnabled) {
|
||||
TextSize[] values = TextSize.values();
|
||||
TextSize restored = textSizeIndex >= 0 && textSizeIndex < values.length
|
||||
? values[textSizeIndex] : TextSize.STANDARD;
|
||||
return new AccessibilitySettings(restored, highContrastEnabled);
|
||||
return new AccessibilitySettings(restored, highContrastEnabled, reducedMotionEnabled);
|
||||
}
|
||||
|
||||
public String textSizeLabel() {
|
||||
@@ -48,7 +56,7 @@ public final class AccessibilitySettings {
|
||||
public AccessibilitySettings nextTextSize() {
|
||||
TextSize[] values = TextSize.values();
|
||||
return new AccessibilitySettings(values[(textSize.ordinal() + 1) % values.length],
|
||||
highContrastEnabled);
|
||||
highContrastEnabled, reducedMotionEnabled);
|
||||
}
|
||||
|
||||
public boolean highContrastEnabled() {
|
||||
@@ -60,7 +68,15 @@ public final class AccessibilitySettings {
|
||||
}
|
||||
|
||||
public AccessibilitySettings toggleHighContrast() {
|
||||
return new AccessibilitySettings(textSize, !highContrastEnabled);
|
||||
return new AccessibilitySettings(textSize, !highContrastEnabled, reducedMotionEnabled);
|
||||
}
|
||||
|
||||
public boolean reducedMotionEnabled() { return reducedMotionEnabled; }
|
||||
|
||||
public String reducedMotionLabel() { return reducedMotionEnabled ? "On" : "Off"; }
|
||||
|
||||
public AccessibilitySettings toggleReducedMotion() {
|
||||
return new AccessibilitySettings(textSize, highContrastEnabled, !reducedMotionEnabled);
|
||||
}
|
||||
|
||||
int textSizeIndex() {
|
||||
|
||||
@@ -15,7 +15,8 @@ final class AccessibilitySettingsStore {
|
||||
AccessibilitySettings defaults = AccessibilitySettings.defaults();
|
||||
return AccessibilitySettings.restore(
|
||||
preferences.getInt("text_size", defaults.textSizeIndex()),
|
||||
preferences.getBoolean("high_contrast", defaults.highContrastEnabled())
|
||||
preferences.getBoolean("high_contrast", defaults.highContrastEnabled()),
|
||||
preferences.getBoolean("reduced_motion", defaults.reducedMotionEnabled())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ final class AccessibilitySettingsStore {
|
||||
preferences.edit()
|
||||
.putInt("text_size", settings.textSizeIndex())
|
||||
.putBoolean("high_contrast", settings.highContrastEnabled())
|
||||
.putBoolean("reduced_motion", settings.reducedMotionEnabled())
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,48 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Records the exact production-input waits emitted by a black-box playtest bot. */
|
||||
public final class BotReplayRecorder implements LevelPlaytestBot.Driver {
|
||||
private final LevelPlaytestBot.Driver delegate;
|
||||
private DeterministicInputTape.Builder tape;
|
||||
private boolean left;
|
||||
private boolean right;
|
||||
private boolean jump;
|
||||
|
||||
public BotReplayRecorder(LevelPlaytestBot.Driver delegate) {
|
||||
if (delegate == null) throw new IllegalArgumentException("Driver is required");
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
tape = DeterministicInputTape.builder(oneBasedLevelNumber);
|
||||
left = right = jump = false;
|
||||
delegate.startLevel(oneBasedLevelNumber);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jumpPressed) {
|
||||
left = moveLeft;
|
||||
right = moveRight;
|
||||
jump = jumpPressed;
|
||||
delegate.setInput(moveLeft, moveRight, jumpPressed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() { return delegate.snapshot(); }
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
if (tape == null) throw new IllegalStateException("A level must start before recording");
|
||||
tape.record(left, right, jump, millis);
|
||||
delegate.waitForNextDecision(millis);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long currentTimeMillis() { return delegate.currentTimeMillis(); }
|
||||
|
||||
public DeterministicInputTape tape() {
|
||||
if (tape == null) throw new IllegalStateException("No replay recorded");
|
||||
return tape.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Deterministic, allocation-free camera follow/focus/shake state. */
|
||||
public final class CameraController {
|
||||
public static final float MAX_FOCUS_OFFSET = 420f;
|
||||
public static final float MAX_SHAKE = 24f;
|
||||
|
||||
public static final class Frame {
|
||||
private float x;
|
||||
private float y;
|
||||
private float shakeX;
|
||||
private float shakeY;
|
||||
private float focusOffsetX;
|
||||
private boolean focusActive;
|
||||
|
||||
public float x() { return x; }
|
||||
public float y() { return y; }
|
||||
public float shakeX() { return shakeX; }
|
||||
public float shakeY() { return shakeY; }
|
||||
public float focusOffsetX() { return focusOffsetX; }
|
||||
public boolean focusActive() { return focusActive; }
|
||||
}
|
||||
|
||||
private static final float SHAKE_SECONDS = 0.28f;
|
||||
private final float viewportWidth;
|
||||
private final float viewportHeight;
|
||||
private final Frame frame = new Frame();
|
||||
private float focusX;
|
||||
private float focusY;
|
||||
private float focusRemaining;
|
||||
private float shakeStrength;
|
||||
private float shakeRemaining;
|
||||
private float shakePhase;
|
||||
|
||||
public CameraController(float viewportWidth, float viewportHeight) {
|
||||
if (viewportWidth <= 0f || viewportHeight <= 0f) {
|
||||
throw new IllegalArgumentException("Camera viewport must be positive");
|
||||
}
|
||||
this.viewportWidth = viewportWidth;
|
||||
this.viewportHeight = viewportHeight;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
frame.x = 0f;
|
||||
frame.y = 0f;
|
||||
frame.shakeX = 0f;
|
||||
frame.shakeY = 0f;
|
||||
frame.focusOffsetX = 0f;
|
||||
frame.focusActive = false;
|
||||
focusRemaining = 0f;
|
||||
shakeStrength = 0f;
|
||||
shakeRemaining = 0f;
|
||||
shakePhase = 0f;
|
||||
}
|
||||
|
||||
public void requestFocus(float worldX, float worldY, float durationSeconds) {
|
||||
focusX = worldX;
|
||||
focusY = worldY;
|
||||
focusRemaining = Math.max(0f, durationSeconds);
|
||||
}
|
||||
|
||||
public void impact(float strength) {
|
||||
shakeStrength = Math.max(shakeStrength, Math.min(MAX_SHAKE, Math.max(0f, strength)));
|
||||
shakeRemaining = shakeStrength > 0f ? SHAKE_SECONDS : 0f;
|
||||
}
|
||||
|
||||
public Frame update(float dt, float playerX, float playerY, int facing,
|
||||
float worldWidth, float worldHeight, boolean reducedMotion) {
|
||||
float safeDt = Math.max(0f, dt);
|
||||
focusRemaining = Math.max(0f, focusRemaining - safeDt);
|
||||
frame.focusActive = focusRemaining > 0f;
|
||||
|
||||
float targetX = playerX - 610f + (facing < 0 ? -110f : 110f);
|
||||
float targetY = playerY - viewportHeight * 0.48f;
|
||||
frame.focusOffsetX = 0f;
|
||||
if (frame.focusActive) {
|
||||
frame.focusOffsetX = clamp(focusX - playerX,
|
||||
-MAX_FOCUS_OFFSET, MAX_FOCUS_OFFSET);
|
||||
targetX += frame.focusOffsetX;
|
||||
targetY += clamp(focusY - playerY,
|
||||
-MAX_FOCUS_OFFSET * 0.5f, MAX_FOCUS_OFFSET * 0.5f);
|
||||
}
|
||||
|
||||
float maxX = Math.max(0f, worldWidth - viewportWidth);
|
||||
float maxY = Math.max(0f, worldHeight - viewportHeight);
|
||||
targetX = clamp(targetX, 0f, maxX);
|
||||
targetY = clamp(targetY, 0f, maxY);
|
||||
float horizontalBlend = 1f - (float) Math.exp(-safeDt * 4.5f);
|
||||
float verticalBlend = 1f - (float) Math.exp(-safeDt * 3.1f);
|
||||
frame.x = clamp(frame.x + (targetX - frame.x) * horizontalBlend, 0f, maxX);
|
||||
frame.y = clamp(frame.y + (targetY - frame.y) * verticalBlend, 0f, maxY);
|
||||
|
||||
shakeRemaining = Math.max(0f, shakeRemaining - safeDt);
|
||||
if (reducedMotion || shakeRemaining <= 0f) {
|
||||
frame.shakeX = 0f;
|
||||
frame.shakeY = 0f;
|
||||
if (shakeRemaining <= 0f) shakeStrength = 0f;
|
||||
} else {
|
||||
shakePhase += safeDt * 71f;
|
||||
float decay = shakeRemaining / SHAKE_SECONDS;
|
||||
frame.shakeX = (float) Math.sin(shakePhase) * shakeStrength * decay;
|
||||
frame.shakeY = (float) Math.cos(shakePhase * 1.37f) * shakeStrength * decay * 0.65f;
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
public Frame frame() {
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static float clamp(float value, float minimum, float maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ public final class CompletionResult {
|
||||
public enum NextGoal {
|
||||
TARGET_DEATHS,
|
||||
TARGET_TIME,
|
||||
TARGET_COLLECTIBLE,
|
||||
NEXT_LEVEL,
|
||||
PERSONAL_BEST
|
||||
}
|
||||
@@ -15,12 +16,15 @@ public final class CompletionResult {
|
||||
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;
|
||||
@@ -28,6 +32,8 @@ public final class CompletionResult {
|
||||
this.bestTimeMs = bestTimeMs;
|
||||
this.targetDeaths = targetDeaths;
|
||||
this.targetTimeMs = targetTimeMs;
|
||||
this.challengeResult = challengeResult;
|
||||
this.bestStars = bestStars;
|
||||
this.nextGoal = nextGoal;
|
||||
this.nextGoalValue = nextGoalValue;
|
||||
}
|
||||
@@ -36,7 +42,21 @@ public final class CompletionResult {
|
||||
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) {
|
||||
@@ -45,6 +65,9 @@ public final class CompletionResult {
|
||||
} 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;
|
||||
@@ -53,7 +76,8 @@ public final class CompletionResult {
|
||||
goalValue = 0L;
|
||||
}
|
||||
return new CompletionResult(currentDeaths, currentTimeMs, bestDeaths, bestTimeMs,
|
||||
level.targetDeaths, targetTimeMs, goal, goalValue);
|
||||
level.targetDeaths, targetTimeMs, currentChallenges, bestChallenges.stars(),
|
||||
goal, goalValue);
|
||||
}
|
||||
|
||||
public int currentDeaths() { return currentDeaths; }
|
||||
@@ -62,6 +86,8 @@ public final class CompletionResult {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,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; }
|
||||
}
|
||||
@@ -22,8 +22,10 @@ public final class GameStrings {
|
||||
|
||||
public String appContentDescription() {
|
||||
return russian()
|
||||
? "Project Sacrifice. Платформенная головоломка из десяти уровней."
|
||||
: "Project Sacrifice. Ten-level puzzle platform game.";
|
||||
? "Project Sacrifice. Платформенная головоломка, уровней: "
|
||||
+ LevelCatalog.all().size() + "."
|
||||
: "Project Sacrifice. Puzzle platform game with "
|
||||
+ LevelCatalog.all().size() + " levels.";
|
||||
}
|
||||
|
||||
public String projectTitle() { return "PROJECT SACRIFICE"; }
|
||||
@@ -43,6 +45,14 @@ public final class GameStrings {
|
||||
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() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически."
|
||||
@@ -79,6 +89,15 @@ public final class GameStrings {
|
||||
public String highContrastValue(boolean enabled) {
|
||||
return hapticsValue(enabled);
|
||||
}
|
||||
public String reducedMotionSetting() { return russian() ? "Меньше движения" : "Reduced motion"; }
|
||||
public String reducedMotionValue(boolean enabled) { return hapticsValue(enabled); }
|
||||
public String audioTitle() { return russian() ? "ЗВУК" : "Audio"; }
|
||||
public String musicSetting() { return russian() ? "Музыка" : "Music"; }
|
||||
public String effectsSetting() { return russian() ? "Эффекты" : "Effects"; }
|
||||
public String voiceSetting() { return russian() ? "Голоса" : "Voice"; }
|
||||
public String volumeValue(AudioSettings settings, int preset) {
|
||||
return settings.volumeLabel(preset);
|
||||
}
|
||||
public String back() { return russian() ? "Назад" : "Back"; }
|
||||
|
||||
public String hudStats(int deaths, long elapsedMilliseconds) {
|
||||
@@ -107,6 +126,20 @@ public final class GameStrings {
|
||||
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"; }
|
||||
@@ -147,6 +180,11 @@ public final class GameStrings {
|
||||
+ 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;
|
||||
@@ -222,6 +260,12 @@ public final class GameStrings {
|
||||
|
||||
public String settingsDescription(ControlSettings controls,
|
||||
AccessibilitySettings accessibility) {
|
||||
return settingsDescription(controls, accessibility, AudioSettings.defaults());
|
||||
}
|
||||
|
||||
public String settingsDescription(ControlSettings controls,
|
||||
AccessibilitySettings accessibility,
|
||||
AudioSettings audio) {
|
||||
if (russian()) {
|
||||
return "Настройки. Размер управления "
|
||||
+ controlSizeValue(controls.controlSizeIndex())
|
||||
@@ -230,6 +274,11 @@ public final class GameStrings {
|
||||
+ ". Вибрация " + hapticsValue(controls.hapticsEnabled())
|
||||
+ ". Размер текста " + textSizeValue(accessibility.textScale())
|
||||
+ ". Контраст " + highContrastValue(accessibility.highContrastEnabled())
|
||||
+ ". Меньше движения "
|
||||
+ reducedMotionValue(accessibility.reducedMotionEnabled())
|
||||
+ ". Музыка " + volumeValue(audio, audio.musicPreset())
|
||||
+ ". Эффекты " + volumeValue(audio, audio.effectsPreset())
|
||||
+ ". Голоса " + volumeValue(audio, audio.voicePreset())
|
||||
+ ". Назад.";
|
||||
}
|
||||
return "Settings. Control size " + controls.controlSizeLabel()
|
||||
@@ -237,6 +286,10 @@ public final class GameStrings {
|
||||
+ ". Vibration " + controls.hapticsLabel()
|
||||
+ ". Text size " + accessibility.textSizeLabel()
|
||||
+ ". High contrast " + accessibility.highContrastLabel()
|
||||
+ ". Reduced motion " + accessibility.reducedMotionLabel()
|
||||
+ ". Music " + volumeValue(audio, audio.musicPreset())
|
||||
+ ". Effects " + volumeValue(audio, audio.effectsPreset())
|
||||
+ ". Voice " + volumeValue(audio, audio.voicePreset())
|
||||
+ ". Back.";
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -257,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.view.WindowInsetsController;
|
||||
import android.view.WindowManager;
|
||||
|
||||
public final class MainActivity extends Activity {
|
||||
private static final String ACTIVE_SESSION_STATE = "active_session_v2";
|
||||
private GameView gameView;
|
||||
|
||||
@Override
|
||||
@@ -17,10 +18,23 @@ public final class MainActivity extends Activity {
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
gameView = new GameView(this);
|
||||
if (savedInstanceState != null) {
|
||||
byte[] encoded = savedInstanceState.getByteArray(ACTIVE_SESSION_STATE);
|
||||
if (encoded != null) gameView.restoreSessionFromActivityState(encoded);
|
||||
}
|
||||
setContentView(gameView);
|
||||
hideSystemUi();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
if (gameView != null) {
|
||||
byte[] encoded = gameView.saveSessionForActivityState();
|
||||
if (encoded != null) outState.putByteArray(ACTIVE_SESSION_STATE, encoded);
|
||||
}
|
||||
super.onSaveInstanceState(outState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Published vertical-slice runtime ceilings for the supported 10-level build. */
|
||||
public final class PerformanceBudget {
|
||||
private static final PerformanceBudget RELEASE = new PerformanceBudget(
|
||||
60, 34f, 12f, 20f, 16, 2_500, 2_200, 128L * 1024L * 1024L);
|
||||
|
||||
private final int targetFramesPerSecond;
|
||||
private final float maximumP95FrameIntervalMs;
|
||||
private final float maximumP95UpdateMs;
|
||||
private final float maximumP95DrawMs;
|
||||
private final int maximumBodies;
|
||||
private final int maximumCollisionChecks;
|
||||
private final int maximumDrawPrimitives;
|
||||
private final long maximumUsedMemoryBytes;
|
||||
|
||||
private PerformanceBudget(int targetFramesPerSecond, float maximumP95FrameIntervalMs,
|
||||
float maximumP95UpdateMs, float maximumP95DrawMs,
|
||||
int maximumBodies, int maximumCollisionChecks,
|
||||
int maximumDrawPrimitives, long maximumUsedMemoryBytes) {
|
||||
this.targetFramesPerSecond = targetFramesPerSecond;
|
||||
this.maximumP95FrameIntervalMs = maximumP95FrameIntervalMs;
|
||||
this.maximumP95UpdateMs = maximumP95UpdateMs;
|
||||
this.maximumP95DrawMs = maximumP95DrawMs;
|
||||
this.maximumBodies = maximumBodies;
|
||||
this.maximumCollisionChecks = maximumCollisionChecks;
|
||||
this.maximumDrawPrimitives = maximumDrawPrimitives;
|
||||
this.maximumUsedMemoryBytes = maximumUsedMemoryBytes;
|
||||
}
|
||||
|
||||
public static PerformanceBudget release() { return RELEASE; }
|
||||
public int targetFramesPerSecond() { return targetFramesPerSecond; }
|
||||
public float maximumP95FrameIntervalMs() { return maximumP95FrameIntervalMs; }
|
||||
public float maximumP95UpdateMs() { return maximumP95UpdateMs; }
|
||||
public float maximumP95DrawMs() { return maximumP95DrawMs; }
|
||||
public int maximumBodies() { return maximumBodies; }
|
||||
public int maximumCollisionChecks() { return maximumCollisionChecks; }
|
||||
public int maximumDrawPrimitives() { return maximumDrawPrimitives; }
|
||||
public long maximumUsedMemoryBytes() { return maximumUsedMemoryBytes; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Allocation-free rolling samples; allocations occur only when a diagnostic snapshot is asked. */
|
||||
public final class PerformanceMonitor {
|
||||
public static final class Snapshot {
|
||||
private final int sampleCount;
|
||||
private final float p95FrameIntervalMs;
|
||||
private final float p95UpdateMs;
|
||||
private final float p95DrawMs;
|
||||
private final int maximumBodies;
|
||||
private final int maximumCollisionChecks;
|
||||
private final int maximumDrawPrimitives;
|
||||
private final long maximumUsedMemoryBytes;
|
||||
|
||||
private Snapshot(int sampleCount, float p95FrameIntervalMs, float p95UpdateMs,
|
||||
float p95DrawMs, int maximumBodies, int maximumCollisionChecks,
|
||||
int maximumDrawPrimitives, long maximumUsedMemoryBytes) {
|
||||
this.sampleCount = sampleCount;
|
||||
this.p95FrameIntervalMs = p95FrameIntervalMs;
|
||||
this.p95UpdateMs = p95UpdateMs;
|
||||
this.p95DrawMs = p95DrawMs;
|
||||
this.maximumBodies = maximumBodies;
|
||||
this.maximumCollisionChecks = maximumCollisionChecks;
|
||||
this.maximumDrawPrimitives = maximumDrawPrimitives;
|
||||
this.maximumUsedMemoryBytes = maximumUsedMemoryBytes;
|
||||
}
|
||||
|
||||
public int sampleCount() { return sampleCount; }
|
||||
public float p95FrameIntervalMs() { return p95FrameIntervalMs; }
|
||||
public float p95UpdateMs() { return p95UpdateMs; }
|
||||
public float p95DrawMs() { return p95DrawMs; }
|
||||
public int maximumBodies() { return maximumBodies; }
|
||||
public int maximumCollisionChecks() { return maximumCollisionChecks; }
|
||||
public int maximumDrawPrimitives() { return maximumDrawPrimitives; }
|
||||
public long maximumUsedMemoryBytes() { return maximumUsedMemoryBytes; }
|
||||
|
||||
public boolean within(PerformanceBudget budget) {
|
||||
return p95FrameIntervalMs <= budget.maximumP95FrameIntervalMs()
|
||||
&& p95UpdateMs <= budget.maximumP95UpdateMs()
|
||||
&& p95DrawMs <= budget.maximumP95DrawMs()
|
||||
&& maximumBodies <= budget.maximumBodies()
|
||||
&& maximumCollisionChecks <= budget.maximumCollisionChecks()
|
||||
&& maximumDrawPrimitives <= budget.maximumDrawPrimitives()
|
||||
&& maximumUsedMemoryBytes <= budget.maximumUsedMemoryBytes();
|
||||
}
|
||||
|
||||
public String failureSummary(PerformanceBudget budget) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
append(result, "frameP95", p95FrameIntervalMs,
|
||||
budget.maximumP95FrameIntervalMs());
|
||||
append(result, "updateP95", p95UpdateMs, budget.maximumP95UpdateMs());
|
||||
append(result, "drawP95", p95DrawMs, budget.maximumP95DrawMs());
|
||||
append(result, "bodies", maximumBodies, budget.maximumBodies());
|
||||
append(result, "collisionChecks", maximumCollisionChecks,
|
||||
budget.maximumCollisionChecks());
|
||||
append(result, "drawPrimitives", maximumDrawPrimitives,
|
||||
budget.maximumDrawPrimitives());
|
||||
append(result, "usedMemory", maximumUsedMemoryBytes,
|
||||
budget.maximumUsedMemoryBytes());
|
||||
return result.length() == 0 ? "within budget" : result.toString();
|
||||
}
|
||||
|
||||
private static void append(StringBuilder result, String name, float actual,
|
||||
float limit) {
|
||||
if (actual <= limit) return;
|
||||
if (result.length() > 0) result.append("; ");
|
||||
result.append(name).append('=')
|
||||
.append(String.format(Locale.US, "%.2f", actual))
|
||||
.append('>').append(String.format(Locale.US, "%.2f", limit));
|
||||
}
|
||||
|
||||
private static void append(StringBuilder result, String name, long actual,
|
||||
long limit) {
|
||||
if (actual <= limit) return;
|
||||
if (result.length() > 0) result.append("; ");
|
||||
result.append(name).append('=').append(actual).append('>').append(limit);
|
||||
}
|
||||
}
|
||||
|
||||
private final float[] frameIntervals;
|
||||
private final float[] updateTimes;
|
||||
private final float[] drawTimes;
|
||||
private int nextIndex;
|
||||
private int sampleCount;
|
||||
private int maximumBodies;
|
||||
private int maximumCollisionChecks;
|
||||
private int maximumDrawPrimitives;
|
||||
private long maximumUsedMemoryBytes;
|
||||
|
||||
public PerformanceMonitor(int windowSize) {
|
||||
if (windowSize < 20) throw new IllegalArgumentException("Window must contain 20+ frames");
|
||||
frameIntervals = new float[windowSize];
|
||||
updateTimes = new float[windowSize];
|
||||
drawTimes = new float[windowSize];
|
||||
}
|
||||
|
||||
public void recordFrame(float intervalMs, float updateMs, float drawMs) {
|
||||
frameIntervals[nextIndex] = finiteNonNegative(intervalMs);
|
||||
updateTimes[nextIndex] = finiteNonNegative(updateMs);
|
||||
drawTimes[nextIndex] = finiteNonNegative(drawMs);
|
||||
nextIndex = (nextIndex + 1) % frameIntervals.length;
|
||||
sampleCount = Math.min(frameIntervals.length, sampleCount + 1);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
Arrays.fill(frameIntervals, 0f);
|
||||
Arrays.fill(updateTimes, 0f);
|
||||
Arrays.fill(drawTimes, 0f);
|
||||
nextIndex = 0;
|
||||
sampleCount = 0;
|
||||
maximumBodies = 0;
|
||||
maximumCollisionChecks = 0;
|
||||
maximumDrawPrimitives = 0;
|
||||
maximumUsedMemoryBytes = 0L;
|
||||
}
|
||||
|
||||
public void recordComplexity(int bodies, int collisionChecks,
|
||||
int drawPrimitives, long usedMemoryBytes) {
|
||||
maximumBodies = Math.max(maximumBodies, Math.max(0, bodies));
|
||||
maximumCollisionChecks = Math.max(maximumCollisionChecks,
|
||||
Math.max(0, collisionChecks));
|
||||
maximumDrawPrimitives = Math.max(maximumDrawPrimitives, Math.max(0, drawPrimitives));
|
||||
maximumUsedMemoryBytes = Math.max(maximumUsedMemoryBytes, Math.max(0L, usedMemoryBytes));
|
||||
}
|
||||
|
||||
public Snapshot snapshot() {
|
||||
return new Snapshot(sampleCount, percentile(frameIntervals, sampleCount, 0.95f),
|
||||
percentile(updateTimes, sampleCount, 0.95f),
|
||||
percentile(drawTimes, sampleCount, 0.95f), maximumBodies,
|
||||
maximumCollisionChecks, maximumDrawPrimitives, maximumUsedMemoryBytes);
|
||||
}
|
||||
|
||||
private static float percentile(float[] source, int count, float fraction) {
|
||||
if (count <= 0) return 0f;
|
||||
float[] values = Arrays.copyOf(source, count);
|
||||
Arrays.sort(values);
|
||||
int index = Math.min(count - 1, Math.max(0, (int) Math.ceil(count * fraction) - 1));
|
||||
return values[index];
|
||||
}
|
||||
|
||||
private static float finiteNonNegative(float value) {
|
||||
if (Float.isNaN(value) || Float.isInfinite(value) || value < 0f) {
|
||||
throw new IllegalArgumentException("Performance sample must be finite and non-negative");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,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));
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,17 @@ public final class AccessibilitySettingsTest {
|
||||
assertEquals(24f, AccessibilitySettings.defaults().canvasTextSize(24f), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reducedMotionIsExplicitReversibleAndRestorable() {
|
||||
AccessibilitySettings settings = AccessibilitySettings.defaults();
|
||||
|
||||
assertFalse(settings.reducedMotionEnabled());
|
||||
assertEquals("Off", settings.reducedMotionLabel());
|
||||
settings = settings.toggleReducedMotion();
|
||||
assertTrue(settings.reducedMotionEnabled());
|
||||
assertEquals("On", settings.reducedMotionLabel());
|
||||
assertFalse(settings.toggleReducedMotion().reducedMotionEnabled());
|
||||
assertTrue(AccessibilitySettings.restore(0, false, true).reducedMotionEnabled());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AudioCueSequencerTest {
|
||||
@Test
|
||||
public void multiVariantDeathVoicesNeverRepeatImmediately() {
|
||||
AudioCueSequencer sequencer = new AudioCueSequencer(17L);
|
||||
int previous = sequencer.nextVariant(AudioCue.DEATH_VOICE);
|
||||
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int next = sequencer.nextVariant(AudioCue.DEATH_VOICE);
|
||||
assertNotEquals(previous, next);
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleVariantCuesRemainStableAndChannelsAreExplicit() {
|
||||
AudioCueSequencer sequencer = new AudioCueSequencer(9L);
|
||||
|
||||
assertEquals(0, sequencer.nextVariant(AudioCue.UI_TAP));
|
||||
assertEquals(0, sequencer.nextVariant(AudioCue.UI_TAP));
|
||||
assertEquals(AudioCue.Channel.EFFECTS, AudioCue.JUMP.channel());
|
||||
assertEquals(AudioCue.Channel.VOICE, AudioCue.DEATH_VOICE.channel());
|
||||
assertEquals(AudioCue.Channel.MUSIC, AudioCue.MUSIC_PULSE.channel());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AudioSettingsTest {
|
||||
@Test
|
||||
public void musicEffectsAndVoiceVolumesCycleIndependently() {
|
||||
AudioSettings settings = AudioSettings.defaults();
|
||||
|
||||
assertEquals(0.66f, settings.musicVolume(), 0f);
|
||||
assertEquals(1f, settings.effectsVolume(), 0f);
|
||||
assertEquals(1f, settings.voiceVolume(), 0f);
|
||||
|
||||
AudioSettings quieterMusic = settings.nextMusicVolume();
|
||||
assertEquals(1f, quieterMusic.musicVolume(), 0f);
|
||||
assertEquals(settings.effectsVolume(), quieterMusic.effectsVolume(), 0f);
|
||||
assertEquals(settings.voiceVolume(), quieterMusic.voiceVolume(), 0f);
|
||||
|
||||
AudioSettings mutedEffects = settings.nextEffectsVolume();
|
||||
assertEquals(0f, mutedEffects.effectsVolume(), 0f);
|
||||
assertEquals(settings.musicVolume(), mutedEffects.musicVolume(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidPersistedPresetsRestoreToSafeDefaults() {
|
||||
AudioSettings restored = AudioSettings.restore(-4, 99, 2);
|
||||
|
||||
assertEquals(AudioSettings.defaults().musicPreset(), restored.musicPreset());
|
||||
assertEquals(AudioSettings.defaults().effectsPreset(), restored.effectsPreset());
|
||||
assertEquals(2, restored.voicePreset());
|
||||
assertEquals("66%", restored.volumeLabel(restored.voicePreset()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class CameraControllerTest {
|
||||
@Test
|
||||
public void followUsesHorizontalAndVerticalDampingWithinWorldBounds() {
|
||||
CameraController camera = new CameraController(1_920f, 1_080f);
|
||||
camera.reset();
|
||||
|
||||
CameraController.Frame frame = camera.update(
|
||||
0.25f, 1_600f, 1_250f, 1, 3_200f, 1_800f, false);
|
||||
|
||||
assertTrue(frame.x() > 0f && frame.x() <= 1_280f);
|
||||
assertTrue(frame.y() > 0f && frame.y() <= 720f);
|
||||
CameraController.Frame clamped = camera.update(
|
||||
5f, 9_000f, 9_000f, 1, 3_200f, 1_800f, false);
|
||||
assertTrue(clamped.x() <= 1_280f);
|
||||
assertTrue(clamped.y() <= 720f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void temporaryFocusIsBoundedAndExpiresBackToPlayerFollow() {
|
||||
CameraController camera = new CameraController(1_920f, 1_080f);
|
||||
camera.reset();
|
||||
camera.requestFocus(2_500f, 400f, 0.35f);
|
||||
|
||||
CameraController.Frame focused = camera.update(
|
||||
0.1f, 700f, 780f, 1, 3_200f, 1_080f, false);
|
||||
assertTrue(focused.focusActive());
|
||||
assertTrue(focused.focusOffsetX() <= CameraController.MAX_FOCUS_OFFSET);
|
||||
|
||||
CameraController.Frame expired = camera.update(
|
||||
0.4f, 700f, 780f, 1, 3_200f, 1_080f, false);
|
||||
assertFalse(expired.focusActive());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void impactShakeIsBoundedAndReducedMotionDisablesIt() {
|
||||
CameraController camera = new CameraController(1_920f, 1_080f);
|
||||
camera.reset();
|
||||
camera.impact(24f);
|
||||
|
||||
CameraController.Frame shaking = camera.update(
|
||||
0.016f, 700f, 780f, 1, 3_200f, 1_080f, false);
|
||||
assertTrue(Math.abs(shaking.shakeX()) <= CameraController.MAX_SHAKE);
|
||||
assertTrue(Math.abs(shaking.shakeY()) <= CameraController.MAX_SHAKE);
|
||||
|
||||
CameraController.Frame reduced = camera.update(
|
||||
0.016f, 700f, 780f, 1, 3_200f, 1_080f, true);
|
||||
assertTrue(reduced.shakeX() == 0f);
|
||||
assertTrue(reduced.shakeY() == 0f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 ChallengeResultTest {
|
||||
@Test
|
||||
public void targetBoundariesEarnAllThreeIndependentStars() {
|
||||
LevelDefinition base = LevelCatalog.get(0);
|
||||
LevelDefinition level = new LevelDefinition(
|
||||
base.formatVersion, base.physicsVersion, base.levelId, base.worldId,
|
||||
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);
|
||||
|
||||
ChallengeResult result = ChallengeResult.evaluate(level,
|
||||
level.targetDeaths, level.targetSeconds * 1_000L, true);
|
||||
|
||||
assertTrue(result.casualtyEarned());
|
||||
assertTrue(result.timeEarned());
|
||||
assertTrue(result.collectibleEarned());
|
||||
assertTrue(result.collectibleAvailable());
|
||||
assertEquals(3, result.stars());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missedGoalsRemainIndependentAndLegacyLevelHasNoImpossibleStar() {
|
||||
LevelDefinition level = LevelCatalog.get(0);
|
||||
|
||||
ChallengeResult slowAndCostly = ChallengeResult.evaluate(level,
|
||||
level.targetDeaths + 1, level.targetSeconds * 1_000L + 1L, false);
|
||||
|
||||
assertFalse(slowAndCostly.casualtyEarned());
|
||||
assertFalse(slowAndCostly.timeEarned());
|
||||
assertFalse(slowAndCostly.collectibleAvailable());
|
||||
assertTrue(slowAndCostly.collectibleEarned());
|
||||
assertEquals(1, slowAndCostly.stars());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class CharacterStateMachineTest {
|
||||
@Test
|
||||
public void groundedVelocitySelectsIdleAndRunDeterministically() {
|
||||
CharacterStateMachine machine = new CharacterStateMachine(PhysicsConfig.release());
|
||||
|
||||
machine.reset(true, true, 0f, 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.IDLE, machine.state());
|
||||
|
||||
machine.observe(1f / 60f, true, true, 370f, 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.RUN, machine.state());
|
||||
|
||||
machine.observe(1f / 60f, true, true, 0f, 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.IDLE, machine.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verticalMotionSelectsJumpFallAndDeathHasPriority() {
|
||||
CharacterStateMachine machine = new CharacterStateMachine(PhysicsConfig.release());
|
||||
|
||||
machine.reset(true, false, 120f, -300f, 1);
|
||||
assertEquals(CharacterStateMachine.State.JUMP, machine.state());
|
||||
|
||||
machine.observe(1f / 60f, true, false, 120f, 80f, 1);
|
||||
assertEquals(CharacterStateMachine.State.FALL, machine.state());
|
||||
|
||||
machine.observe(1f / 60f, false, true, 0f, 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.DEATH, machine.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changingFacingEmitsOneConfiguredTurnWindow() {
|
||||
PhysicsConfig physics = PhysicsConfig.release();
|
||||
CharacterStateMachine machine = new CharacterStateMachine(physics);
|
||||
machine.reset(true, true, physics.runSpeed(), 0f, 1);
|
||||
|
||||
machine.observe(physics.fixedStepSeconds(), true, true,
|
||||
-physics.runSpeed(), 0f, -1);
|
||||
assertEquals(CharacterStateMachine.State.TURN, machine.state());
|
||||
|
||||
machine.observe(physics.turnSeconds() + physics.fixedStepSeconds(),
|
||||
true, true, -physics.runSpeed(), 0f, -1);
|
||||
assertEquals(CharacterStateMachine.State.RUN, machine.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstGroundedFrameAfterFallingEmitsConfiguredLandWindow() {
|
||||
PhysicsConfig physics = PhysicsConfig.release();
|
||||
CharacterStateMachine machine = new CharacterStateMachine(physics);
|
||||
machine.reset(true, false, 0f, 300f, 1);
|
||||
|
||||
machine.observe(physics.fixedStepSeconds(), true, true, 0f, 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.LAND, machine.state());
|
||||
|
||||
machine.observe(physics.landSeconds() + physics.fixedStepSeconds(),
|
||||
true, true, 0f, 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.IDLE, machine.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void interactionIsAConfiguredCommandAndDeathCancelsIt() {
|
||||
PhysicsConfig physics = PhysicsConfig.release();
|
||||
CharacterStateMachine machine = new CharacterStateMachine(physics);
|
||||
machine.reset(true, true, 0f, 0f, 1);
|
||||
|
||||
machine.beginInteraction();
|
||||
assertEquals(CharacterStateMachine.State.INTERACT, machine.state());
|
||||
machine.observe(physics.interactSeconds() * 0.5f,
|
||||
true, true, 0f, 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.INTERACT, machine.state());
|
||||
|
||||
machine.observe(physics.fixedStepSeconds(), false, true, 0f, 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.DEATH, machine.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stunOverridesInteractionThenReturnsToObservedMovement() {
|
||||
PhysicsConfig physics = PhysicsConfig.release();
|
||||
CharacterStateMachine machine = new CharacterStateMachine(physics);
|
||||
machine.reset(true, true, physics.runSpeed(), 0f, 1);
|
||||
machine.beginInteraction();
|
||||
|
||||
machine.stun();
|
||||
assertEquals(CharacterStateMachine.State.STUNNED, machine.state());
|
||||
machine.observe(physics.stunnedSeconds() * 0.5f,
|
||||
true, true, physics.runSpeed(), 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.STUNNED, machine.state());
|
||||
|
||||
machine.observe(physics.stunnedSeconds(),
|
||||
true, true, physics.runSpeed(), 0f, 1);
|
||||
assertEquals(CharacterStateMachine.State.RUN, machine.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void interactionCannotDowngradeAnActiveStun() {
|
||||
CharacterStateMachine machine = new CharacterStateMachine(PhysicsConfig.release());
|
||||
machine.reset(true, true, 0f, 0f, 1);
|
||||
|
||||
machine.stun();
|
||||
machine.beginInteraction();
|
||||
|
||||
assertEquals(CharacterStateMachine.State.STUNNED, machine.state());
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,35 @@ public final class CompletionResultTest {
|
||||
LevelDefinition last = LevelCatalog.get(LevelCatalog.all().size() - 1);
|
||||
CompletionResult lastResult = CompletionResult.create(last,
|
||||
last.targetDeaths, last.targetSeconds * 900L,
|
||||
last.targetDeaths, last.targetSeconds * 900L, false);
|
||||
last.targetDeaths, last.targetSeconds * 900L,
|
||||
true, true, false);
|
||||
assertEquals(CompletionResult.NextGoal.PERSONAL_BEST, lastResult.nextGoal());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collectibleIsThirdExplicitGoalAndStarsExposeCurrentAndBest() {
|
||||
LevelDefinition base = LevelCatalog.get(0);
|
||||
LevelDefinition level = new LevelDefinition(
|
||||
base.formatVersion, base.physicsVersion, "foundations.result-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);
|
||||
|
||||
CompletionResult result = CompletionResult.create(level, base.targetDeaths,
|
||||
base.targetSeconds * 1_000L, base.targetDeaths,
|
||||
base.targetSeconds * 900L, false, false, true);
|
||||
|
||||
assertEquals(2, result.challengeResult().stars());
|
||||
assertEquals(2, result.bestStars());
|
||||
assertEquals(CompletionResult.NextGoal.TARGET_COLLECTIBLE, result.nextGoal());
|
||||
|
||||
CompletionResult mastered = CompletionResult.create(level, base.targetDeaths,
|
||||
base.targetSeconds * 1_000L, base.targetDeaths,
|
||||
base.targetSeconds * 900L, true, true, true);
|
||||
assertEquals(3, mastered.challengeResult().stars());
|
||||
assertEquals(3, mastered.bestStars());
|
||||
assertEquals(CompletionResult.NextGoal.NEXT_LEVEL, mastered.nextGoal());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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 org.junit.Test;
|
||||
|
||||
public final class CorpseManagerTest {
|
||||
@Test
|
||||
public void registeringBeyondTheBudgetEvictsTheOldestBodyAndReportsIt() {
|
||||
CorpseManager manager = new CorpseManager(2);
|
||||
CorpseManager.Body first = manager.register("SPIKE", 10f, 20f);
|
||||
CorpseManager.Body second = manager.register("SAW", 30f, 40f);
|
||||
CorpseManager.Body third = manager.register("FREEZE", 50f, 60f);
|
||||
|
||||
assertEquals(2, manager.count());
|
||||
assertSame(second, manager.bodies().get(0));
|
||||
assertSame(third, manager.bodies().get(1));
|
||||
assertEquals(3L, manager.diagnostics().registered());
|
||||
assertEquals(1L, manager.diagnostics().evicted());
|
||||
assertEquals(first.id() + 1L, second.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removeAndResetAreIdempotentAndHaveSeparateDiagnostics() {
|
||||
CorpseManager manager = new CorpseManager(3);
|
||||
CorpseManager.Body first = manager.register("SPIKE", 10f, 20f);
|
||||
manager.register("SAW", 30f, 40f);
|
||||
|
||||
assertTrue(manager.remove(first));
|
||||
assertFalse(manager.remove(first));
|
||||
manager.reset();
|
||||
|
||||
assertEquals(0, manager.count());
|
||||
assertEquals(1L, manager.diagnostics().removed());
|
||||
assertEquals(1L, manager.diagnostics().resets());
|
||||
assertEquals(1L, manager.diagnostics().clearedByReset());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownedBodiesExposePhysicsFactsAndComposableTransformations() {
|
||||
CorpseManager manager = new CorpseManager(3);
|
||||
CorpseManager.Body body = manager.register("ELECTRIC", 10f, 20f)
|
||||
.position(30f, 40f)
|
||||
.size(104f, 54f)
|
||||
.velocity(12f, -8f)
|
||||
.grounded(true)
|
||||
.supportIndex(2);
|
||||
|
||||
assertTrue(manager.transform(body, CorpseManager.Transformation.FROZEN));
|
||||
assertTrue(manager.transform(body, CorpseManager.Transformation.CONDUCTIVE));
|
||||
assertTrue(manager.transform(body, CorpseManager.Transformation.PINNED));
|
||||
|
||||
assertEquals(30f, body.x(), 0f);
|
||||
assertEquals(40f, body.y(), 0f);
|
||||
assertEquals(104f, body.width(), 0f);
|
||||
assertEquals(54f, body.height(), 0f);
|
||||
assertEquals(12f, body.velocityX(), 0f);
|
||||
assertEquals(-8f, body.velocityY(), 0f);
|
||||
assertTrue(body.frozen());
|
||||
assertTrue(body.conductive());
|
||||
assertTrue(body.pinned());
|
||||
assertTrue(body.grounded());
|
||||
assertEquals(2, body.supportIndex());
|
||||
|
||||
CorpseManager.Body foreign = new CorpseManager(1).register("SAW", 0f, 0f);
|
||||
assertFalse(manager.transform(foreign, CorpseManager.Transformation.FROZEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void burnedBodyRemainsPhysicalButCannotActivatePressureMechanisms() {
|
||||
CorpseManager manager = new CorpseManager(2);
|
||||
CorpseManager.Body body = manager.register("FIRE", 10f, 20f);
|
||||
|
||||
assertTrue(manager.transform(body, CorpseManager.Transformation.BURNED));
|
||||
|
||||
assertTrue(body.burned());
|
||||
assertFalse(body.crushed());
|
||||
assertFalse(body.activatesPressure());
|
||||
assertTrue(manager.bodies().contains(body));
|
||||
assertTrue(body instanceof IWeightedObject);
|
||||
assertEquals(1, body.pressureMass());
|
||||
assertEquals(body.x(), body.weightedX(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void crushedBodyKeepsOtherTransformationsAndExposesItsLowProfile() {
|
||||
CorpseManager manager = new CorpseManager(2);
|
||||
CorpseManager.Body body = manager.register("CRUSH", 10f, 20f)
|
||||
.size(140f, 24f);
|
||||
|
||||
assertTrue(manager.transform(body, CorpseManager.Transformation.PINNED));
|
||||
assertTrue(manager.transform(body, CorpseManager.Transformation.CRUSHED));
|
||||
|
||||
assertTrue(body.crushed());
|
||||
assertFalse(body.burned());
|
||||
assertTrue(body.pinned());
|
||||
assertTrue(body.activatesPressure());
|
||||
assertEquals(140f, body.width(), 0f);
|
||||
assertEquals(24f, body.height(), 0f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class DeterministicInputTapeTest {
|
||||
@Test
|
||||
public void oneRecordedCommandTapeReplaysToTheSameTerminalSignature() {
|
||||
DeterministicInputTape tape = DeterministicInputTape.builder(2)
|
||||
.record(false, true, false, 16L)
|
||||
.record(false, true, true, 32L)
|
||||
.record(false, false, false, 16L)
|
||||
.build();
|
||||
|
||||
FakeTarget first = new FakeTarget();
|
||||
FakeTarget second = new FakeTarget();
|
||||
|
||||
assertEquals(tape.play(first), tape.play(second));
|
||||
assertEquals("level=2;ticks=64;mask=0", first.terminalSignature());
|
||||
assertEquals(3, tape.commands().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tapeIdIsStableButChangesWithCommands() {
|
||||
DeterministicInputTape first = DeterministicInputTape.builder(1)
|
||||
.record(false, true, false, 16L).build();
|
||||
DeterministicInputTape same = DeterministicInputTape.builder(1)
|
||||
.record(false, true, false, 16L).build();
|
||||
DeterministicInputTape changed = DeterministicInputTape.builder(1)
|
||||
.record(false, true, true, 16L).build();
|
||||
|
||||
assertEquals(first.id(), same.id());
|
||||
assertNotEquals(first.id(), changed.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terminalStateSignatureIncludesPuzzleBytesAndCompletionFlags() {
|
||||
byte[] puzzle = {4, 8, 15, 16, 23, 42};
|
||||
|
||||
assertEquals(ReplayStateSignature.of(puzzle, true, false),
|
||||
ReplayStateSignature.of(puzzle, true, false));
|
||||
assertNotEquals(ReplayStateSignature.of(puzzle, true, false),
|
||||
ReplayStateSignature.of(puzzle, false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void productionTeachingLevelsCanOwnVersionedInputTapes() {
|
||||
DeterministicInputTape tape = DeterministicInputTape.builder(14)
|
||||
.record(false, true, true, 250L)
|
||||
.build();
|
||||
|
||||
assertEquals(14, tape.levelNumber());
|
||||
}
|
||||
|
||||
private static final class FakeTarget implements DeterministicInputTape.Target {
|
||||
private int level;
|
||||
private long ticks;
|
||||
private int mask;
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) { level = oneBasedLevelNumber; }
|
||||
|
||||
@Override
|
||||
public void setInput(boolean left, boolean right, boolean jump) {
|
||||
mask = (left ? 1 : 0) | (right ? 2 : 0) | (jump ? 4 : 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void advance(long milliseconds) { ticks += milliseconds; }
|
||||
|
||||
@Override
|
||||
public String terminalSignature() {
|
||||
return "level=" + level + ";ticks=" + ticks + ";mask=" + mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class FoundationsAdvancedContentTest {
|
||||
@Test
|
||||
public void secondFoundationsWaveMatchesMatrixAndPreservesRecoveryContracts() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertTrue(levels.size() >= 26);
|
||||
|
||||
LevelDefinition remote = levels.get(22);
|
||||
assertIdentity(remote, 23, "foundations.remote-volunteer", "Remote Volunteer", 1, 50);
|
||||
assertTrue(remote.containsMechanic(LevelDefinition.HazardType.SAW));
|
||||
assertTrue(remote.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
assertFalse(remote.doors.isEmpty());
|
||||
|
||||
LevelDefinition frozen = levels.get(23);
|
||||
assertIdentity(frozen, 24, "foundations.frozen-step", "Frozen Step", 1, 48);
|
||||
assertTrue(frozen.containsMechanic(LevelDefinition.HazardType.FREEZER));
|
||||
assertTrue(frozen.goal.y < 700f);
|
||||
|
||||
LevelDefinition live = levels.get(24);
|
||||
assertIdentity(live, 25, "foundations.living-wire", "Living Wire", 1, 52);
|
||||
assertTrue(live.containsMechanic(LevelDefinition.HazardType.ELECTRIC));
|
||||
assertFalse(live.doors.isEmpty());
|
||||
|
||||
LevelDefinition timed = levels.get(25);
|
||||
assertIdentity(timed, 26, "foundations.borrowed-time", "Second Hand", 0, 55);
|
||||
assertTrue(timed.containsMechanic(LevelDefinition.SwitchType.TIMED));
|
||||
assertTrue(timed.containsMechanic(LevelDefinition.HazardType.PIT));
|
||||
assertTrue(timed.hasMovingPlatform());
|
||||
assertFalse(timed.doors.isEmpty());
|
||||
assertEquals(2, timed.switches.size());
|
||||
Set<String> signals = new HashSet<>();
|
||||
for (LevelDefinition.Switch item : timed.switches) signals.add(item.signalId);
|
||||
assertEquals(Set.of(timed.platforms.get(3).controlId), signals);
|
||||
}
|
||||
|
||||
private static void assertIdentity(LevelDefinition level, int number, String id, String title,
|
||||
int targetDeaths, int targetSeconds) {
|
||||
assertEquals(number, level.number);
|
||||
assertEquals(id, level.levelId);
|
||||
assertEquals("foundations", level.worldId);
|
||||
assertEquals(title, level.title);
|
||||
assertEquals(targetDeaths, level.targetDeaths);
|
||||
assertEquals(targetSeconds, level.targetSeconds);
|
||||
assertNotNull(level.collectible);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class FoundationsContentTest {
|
||||
@Test
|
||||
public void firstFoundationsWaveMatchesTheApprovedMatrixAndTeachesOneIdeaAtATime() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertTrue(levels.size() >= 22);
|
||||
|
||||
LevelDefinition firstReturn = levels.get(18);
|
||||
assertIdentity(firstReturn, 19, "foundations.first-return", "First Return", 0, 28);
|
||||
assertTrue(firstReturn.hazards.isEmpty());
|
||||
assertTrue(firstReturn.switches.isEmpty());
|
||||
assertTrue(firstReturn.doors.isEmpty());
|
||||
|
||||
LevelDefinition coveredPath = levels.get(19);
|
||||
assertIdentity(coveredPath, 20, "foundations.covered-path", "Covered Path", 2, 42);
|
||||
assertTrue(coveredPath.containsMechanic(LevelDefinition.HazardType.SPIKES));
|
||||
assertTrue(coveredPath.switches.isEmpty());
|
||||
|
||||
LevelDefinition heldOpen = levels.get(20);
|
||||
assertIdentity(heldOpen, 21, "foundations.held-open", "Held Open", 1, 45);
|
||||
assertTrue(heldOpen.containsMechanic(LevelDefinition.HazardType.SAW));
|
||||
assertTrue(heldOpen.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
assertFalse(heldOpen.doors.isEmpty());
|
||||
assertEquals(heldOpen.switches.get(0).signalId,
|
||||
heldOpen.doors.get(0).requiredSignals.get(0));
|
||||
|
||||
LevelDefinition clockwork = levels.get(21);
|
||||
assertIdentity(clockwork, 22, "foundations.clockwork-step", "Clockwork Step", 0, 48);
|
||||
assertTrue(clockwork.containsMechanic(LevelDefinition.SwitchType.TOGGLE));
|
||||
assertTrue(clockwork.containsMechanic(LevelDefinition.HazardType.PIT));
|
||||
assertTrue(clockwork.hasMovingPlatform());
|
||||
assertFalse(clockwork.platforms.get(2).controlId.isEmpty());
|
||||
assertEquals(clockwork.switches.get(0).signalId, clockwork.platforms.get(2).controlId);
|
||||
}
|
||||
|
||||
private static void assertIdentity(LevelDefinition level, int number, String id, String title,
|
||||
int targetDeaths, int targetSeconds) {
|
||||
assertEquals(number, level.number);
|
||||
assertEquals(id, level.levelId);
|
||||
assertEquals("foundations", level.worldId);
|
||||
assertEquals(title, level.title);
|
||||
assertEquals(targetDeaths, level.targetDeaths);
|
||||
assertEquals(targetSeconds, level.targetSeconds);
|
||||
assertNotNull(level.collectible);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class FoundationsFinaleContentTest {
|
||||
@Test
|
||||
public void finalFoundationsWaveMatchesTheApprovedMatrixAndClosesTheWorld() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertTrue(levels.size() >= 30);
|
||||
|
||||
LevelDefinition keys = levels.get(26);
|
||||
assertIdentity(keys, 27, "foundations.two-keys", "Two Keys", 2, 65);
|
||||
assertTrue(keys.containsMechanic(LevelDefinition.HazardType.ELECTRIC));
|
||||
assertTrue(keys.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
assertFalse(keys.doors.isEmpty());
|
||||
assertEquals(2, keys.doors.get(0).requiredSignals.size());
|
||||
|
||||
LevelDefinition restart = levels.get(27);
|
||||
assertIdentity(restart, 28, "foundations.clean-restart", "Clean Restart", 3, 75);
|
||||
assertTrue(restart.containsMechanic(LevelDefinition.HazardType.SAW));
|
||||
assertTrue(restart.containsMechanic(LevelDefinition.HazardType.FREEZER));
|
||||
assertTrue(restart.containsMechanic(LevelDefinition.HazardType.ELECTRIC));
|
||||
|
||||
LevelDefinition challenge = levels.get(28);
|
||||
assertIdentity(challenge, 29, "foundations.minimum-cost", "Minimum Cost", 2, 65);
|
||||
assertTrue(challenge.containsMechanic(LevelDefinition.HazardType.SPIKES));
|
||||
assertTrue(challenge.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
assertTrue(challenge.containsMechanic(LevelDefinition.SwitchType.TIMED));
|
||||
assertFalse(challenge.doors.isEmpty());
|
||||
|
||||
LevelDefinition finale = levels.get(29);
|
||||
assertIdentity(finale, 30, "foundations.foundation-final", "Foundation Relay", 3, 90);
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.HazardType.SPIKES));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.HazardType.SAW));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.HazardType.PIT));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.SwitchType.TOGGLE));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.SwitchType.TIMED));
|
||||
assertTrue(finale.hasMovingPlatform());
|
||||
assertFalse(finale.doors.isEmpty());
|
||||
assertEquals(2, finale.doors.get(0).requiredSignals.size());
|
||||
}
|
||||
|
||||
private static void assertIdentity(LevelDefinition level, int number, String id, String title,
|
||||
int targetDeaths, int targetSeconds) {
|
||||
assertEquals(number, level.number);
|
||||
assertEquals(id, level.levelId);
|
||||
assertEquals("foundations", level.worldId);
|
||||
assertEquals(title, level.title);
|
||||
assertEquals(targetDeaths, level.targetDeaths);
|
||||
assertEquals(targetSeconds, level.targetSeconds);
|
||||
assertNotNull(level.collectible);
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,20 @@ public final class GameStringsTest {
|
||||
assertEquals("Русский", russian.languageName());
|
||||
assertEquals("Рыцарь-доброволец", russian.levelTitle(1));
|
||||
assertEquals("Последнее шествие", russian.levelTitle(10));
|
||||
assertEquals("Паровой заряд", russian.levelTitle(14));
|
||||
assertEquals("Page 2 of 2", english.pageIndicator(2, 2));
|
||||
assertEquals("Страница 2 из 2", russian.pageIndicator(2, 2));
|
||||
assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L));
|
||||
assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L));
|
||||
assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3));
|
||||
assertEquals("★★☆", english.starRow(2));
|
||||
assertEquals("★ Тайник найден", russian.completionCollectible(true, true));
|
||||
assertEquals("☆ Тайник не найден", russian.completionCollectible(false, true));
|
||||
assertEquals("Следующая цель · найти тайник", russian.nextGoalCollectible());
|
||||
assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle());
|
||||
assertFalse(russian.tutorialSacrificeBody().trim().isEmpty());
|
||||
|
||||
for (int level = 1; level <= 10; level++) {
|
||||
for (int level = 1; level <= LevelCatalog.all().size(); level++) {
|
||||
assertFalse(russian.levelTitle(level).trim().isEmpty());
|
||||
assertFalse(russian.levelHint(level).trim().isEmpty());
|
||||
assertNotEquals(english.levelTitle(level), russian.levelTitle(level));
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class HazardRuntimeTest {
|
||||
@Test
|
||||
public void crusherActiveWindowRepeatsOnItsDataDrivenCycle() {
|
||||
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
|
||||
LevelDefinition.HazardType.CRUSHER,
|
||||
new LevelDefinition.Box(620f, 690f, 150f, 190f),
|
||||
null, 0f, 180f, 4f, 1.25f, 0f, 0f,
|
||||
"brass-press", "crusher-impact");
|
||||
HazardRuntime runtime = new HazardRuntime(definition);
|
||||
|
||||
assertTrue(runtime.active());
|
||||
runtime.update(1.2f);
|
||||
assertTrue(runtime.active());
|
||||
runtime.update(0.1f);
|
||||
assertFalse(runtime.active());
|
||||
runtime.update(2.7f);
|
||||
assertTrue(runtime.active());
|
||||
runtime.update(4f);
|
||||
assertTrue(runtime.active());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectileMovesFromItsSpawnBoxAndWrapsAtTheConfiguredCycle() {
|
||||
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
|
||||
LevelDefinition.HazardType.PROJECTILE,
|
||||
new LevelDefinition.Box(620f, 790f, 48f, 48f),
|
||||
null, 420f, -120f, 3f, 0f, 360f, 0f,
|
||||
"clockwork-bolt", "projectile-hit");
|
||||
HazardRuntime runtime = new HazardRuntime(definition);
|
||||
|
||||
runtime.update(1f);
|
||||
assertTrue(runtime.currentBox().x == 980f);
|
||||
runtime.update(2f);
|
||||
assertTrue(runtime.currentBox().x == 620f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explosionHasADataDrivenBlastAndRestWindow() {
|
||||
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
|
||||
LevelDefinition.HazardType.EXPLOSION,
|
||||
new LevelDefinition.Box(620f, 740f, 140f, 140f),
|
||||
null, 550f, -500f, 4f, 2f, 0f, 0f,
|
||||
"steam-blast", "explosion-blast");
|
||||
HazardRuntime runtime = new HazardRuntime(definition);
|
||||
|
||||
assertTrue(runtime.active());
|
||||
runtime.update(2.1f);
|
||||
assertFalse(runtime.active());
|
||||
runtime.update(1.9f);
|
||||
assertTrue(runtime.active());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -26,4 +28,26 @@ public final class LevelCatalogLoaderTest {
|
||||
assertEquals(1, levels.get(0).number);
|
||||
assertEquals("The Test", levels.get(0).title);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateStableLevelIdsFailBeforeProgressCanCollide() {
|
||||
Map<String, String> resources = new HashMap<>();
|
||||
resources.put("levels/index.json", "{\"formatVersion\":1,\"physicsVersion\":1,"
|
||||
+ "\"levels\":[\"level-01.json\",\"level-02.json\"]}");
|
||||
resources.put("levels/level-01.json", LevelJsonLoaderTest.validLevelJson(1)
|
||||
.replace("\"number\":1,", "\"number\":1,\"id\":\"shared.level\","));
|
||||
resources.put("levels/level-02.json", LevelJsonLoaderTest.validLevelJson(1)
|
||||
.replace("\"number\":1,", "\"number\":2,\"id\":\"shared.level\","));
|
||||
|
||||
try {
|
||||
LevelCatalogLoader.load(path -> {
|
||||
String value = resources.get(path);
|
||||
return value == null ? null : new StringReader(value);
|
||||
}, "levels/index.json");
|
||||
fail("Duplicate stable IDs must fail before they share one progress record");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains(
|
||||
"levels/index.json.levels[1] duplicates level id 'shared.level'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class LevelCatalogTest {
|
||||
@Test
|
||||
public void verticalSliceContainsTenSequentialLevels() {
|
||||
public void productionCatalogContainsMechanicsAndChallengeTeachingLevels() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertEquals(10, levels.size());
|
||||
assertEquals(34, levels.size());
|
||||
Set<String> titles = new HashSet<>();
|
||||
for (int i = 0; i < levels.size(); i++) {
|
||||
LevelDefinition level = levels.get(i);
|
||||
@@ -22,6 +23,27 @@ public final class LevelCatalogTest {
|
||||
assertTrue(titles.add(level.title));
|
||||
assertFalse(level.hint.trim().isEmpty());
|
||||
}
|
||||
assertTrue(levels.get(10).containsMechanic(LevelDefinition.HazardType.CRUSHER));
|
||||
assertTrue(levels.get(11).containsMechanic(LevelDefinition.HazardType.FIRE));
|
||||
assertTrue(levels.get(12).containsMechanic(LevelDefinition.HazardType.PROJECTILE));
|
||||
assertTrue(levels.get(13).containsMechanic(LevelDefinition.HazardType.EXPLOSION));
|
||||
assertTrue(levels.get(14).containsMechanic(LevelDefinition.HazardType.CRUSHER));
|
||||
assertTrue(levels.get(15).containsMechanic(LevelDefinition.HazardType.FIRE));
|
||||
assertFalse(levels.get(15).platforms.get(1).controlId.isEmpty());
|
||||
LevelDefinition weighted = levels.get(16);
|
||||
assertEquals(5, weighted.objects.size());
|
||||
Set<LevelDefinition.PhysicalObjectType> objectTypes = new HashSet<>();
|
||||
for (LevelDefinition.PhysicalObject object : weighted.objects) {
|
||||
objectTypes.add(object.type);
|
||||
}
|
||||
assertEquals(new HashSet<>(Arrays.asList(LevelDefinition.PhysicalObjectType.values())),
|
||||
objectTypes);
|
||||
assertEquals(9, weighted.switches.get(0).requiredMass);
|
||||
assertFalse(weighted.doors.isEmpty());
|
||||
LevelDefinition challenge = levels.get(17);
|
||||
assertEquals("foundations.threefold-proof", challenge.levelId);
|
||||
assertEquals("foundations", challenge.worldId);
|
||||
assertTrue(challenge.collectible != null);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class LevelIdentityTest {
|
||||
@Test
|
||||
public void everyCatalogEntryHasStableUniqueLevelAndWorldIds() {
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (LevelDefinition level : LevelCatalog.all()) {
|
||||
assertNotNull(level.levelId);
|
||||
assertNotNull(level.worldId);
|
||||
assertTrue(level.levelId.matches("[a-z0-9][a-z0-9._-]{2,63}"));
|
||||
assertTrue(level.worldId.matches("[a-z0-9][a-z0-9._-]{2,31}"));
|
||||
assertTrue("duplicate levelId " + level.levelId, ids.add(level.levelId));
|
||||
}
|
||||
assertEquals(LevelCatalog.all().size(), ids.size());
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,161 @@ public final class LevelJsonLoaderTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void crusherLoadsItsCycleImpulseAudioAndVisualSettingsFromData() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"CRUSHER\","
|
||||
+ "\"box\":{\"x\":620,\"y\":690,\"width\":150,\"height\":190},"
|
||||
+ "\"impulseX\":0,\"impulseY\":180,"
|
||||
+ "\"cycleSeconds\":4,\"activeSeconds\":1.25,"
|
||||
+ "\"speedX\":0,\"speedY\":0,"
|
||||
+ "\"visualStyle\":\"brass-press\",\"audioCue\":\"crusher-impact\"}],"
|
||||
+ "\"switches\":[],\"doors\":[]");
|
||||
|
||||
LevelDefinition.Hazard crusher = LevelJsonLoader.load(
|
||||
new StringReader(json), "crusher.json").hazards.get(0);
|
||||
|
||||
assertEquals(LevelDefinition.HazardType.CRUSHER, crusher.type);
|
||||
assertEquals(4f, crusher.cycleSeconds, 0f);
|
||||
assertEquals(1.25f, crusher.activeSeconds, 0f);
|
||||
assertEquals(180f, crusher.impulseY, 0f);
|
||||
assertEquals("brass-press", crusher.visualStyle);
|
||||
assertEquals("crusher-impact", crusher.audioCue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fireLoadsItsCorpseImpulseAudioAndVisualSettingsFromData() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"FIRE\","
|
||||
+ "\"box\":{\"x\":700,\"y\":790,\"width\":120,\"height\":90},"
|
||||
+ "\"impulseX\":160,\"impulseY\":-220,"
|
||||
+ "\"visualStyle\":\"blue-flame\",\"audioCue\":\"fire-burst\"}],"
|
||||
+ "\"switches\":[],\"doors\":[]");
|
||||
|
||||
LevelDefinition.Hazard fire = LevelJsonLoader.load(
|
||||
new StringReader(json), "fire.json").hazards.get(0);
|
||||
|
||||
assertEquals(LevelDefinition.HazardType.FIRE, fire.type);
|
||||
assertEquals(160f, fire.impulseX, 0f);
|
||||
assertEquals(-220f, fire.impulseY, 0f);
|
||||
assertEquals("blue-flame", fire.visualStyle);
|
||||
assertEquals("fire-burst", fire.audioCue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectileLoadsItsVelocityCycleAudioAndVisualSettingsFromData() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"PROJECTILE\","
|
||||
+ "\"box\":{\"x\":620,\"y\":790,\"width\":48,\"height\":48},"
|
||||
+ "\"impulseX\":420,\"impulseY\":-120,"
|
||||
+ "\"cycleSeconds\":3,\"activeSeconds\":0,"
|
||||
+ "\"speedX\":360,\"speedY\":0,"
|
||||
+ "\"visualStyle\":\"clockwork-bolt\",\"audioCue\":\"projectile-hit\"}],"
|
||||
+ "\"switches\":[],\"doors\":[]");
|
||||
|
||||
LevelDefinition.Hazard projectile = LevelJsonLoader.load(
|
||||
new StringReader(json), "projectile.json").hazards.get(0);
|
||||
|
||||
assertEquals(LevelDefinition.HazardType.PROJECTILE, projectile.type);
|
||||
assertEquals(3f, projectile.cycleSeconds, 0f);
|
||||
assertEquals(360f, projectile.speedX, 0f);
|
||||
assertEquals("clockwork-bolt", projectile.visualStyle);
|
||||
assertEquals("projectile-hit", projectile.audioCue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explosionLoadsItsRequiredImpulseAudioAndVisualSettingsFromData() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"EXPLOSION\","
|
||||
+ "\"box\":{\"x\":720,\"y\":740,\"width\":140,\"height\":140},"
|
||||
+ "\"impulseX\":520,\"impulseY\":-380,"
|
||||
+ "\"cycleSeconds\":4,\"activeSeconds\":2,"
|
||||
+ "\"visualStyle\":\"steam-blast\",\"audioCue\":\"explosion-blast\"}],"
|
||||
+ "\"switches\":[],\"doors\":[]");
|
||||
|
||||
LevelDefinition.Hazard explosion = LevelJsonLoader.load(
|
||||
new StringReader(json), "explosion.json").hazards.get(0);
|
||||
|
||||
assertEquals(LevelDefinition.HazardType.EXPLOSION, explosion.type);
|
||||
assertEquals(520f, explosion.impulseX, 0f);
|
||||
assertEquals(-380f, explosion.impulseY, 0f);
|
||||
assertEquals(4f, explosion.cycleSeconds, 0f);
|
||||
assertEquals(2f, explosion.activeSeconds, 0f);
|
||||
assertEquals("steam-blast", explosion.visualStyle);
|
||||
assertEquals("explosion-blast", explosion.audioCue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allApprovedWeightedObjectTypesLoadThroughOneMassContract() {
|
||||
String objects = "\"objects\":["
|
||||
+ "{\"type\":\"BOX\",\"box\":{\"x\":300,\"y\":800,\"width\":80,\"height\":80},\"mass\":1},"
|
||||
+ "{\"type\":\"ROCK\",\"box\":{\"x\":400,\"y\":820,\"width\":60,\"height\":60},\"mass\":2},"
|
||||
+ "{\"type\":\"BALL\",\"box\":{\"x\":500,\"y\":820,\"width\":60,\"height\":60},\"mass\":1},"
|
||||
+ "{\"type\":\"ICE_BLOCK\",\"box\":{\"x\":600,\"y\":800,\"width\":80,\"height\":80},\"mass\":2},"
|
||||
+ "{\"type\":\"COUNTERWEIGHT\",\"box\":{\"x\":700,\"y\":780,\"width\":80,\"height\":100},\"mass\":3}],";
|
||||
String json = validLevelJson(1).replace("\"platforms\":", objects + "\"platforms\":");
|
||||
|
||||
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "weighted.json");
|
||||
|
||||
assertEquals(5, level.objects.size());
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.BOX, level.objects.get(0).type);
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.ROCK, level.objects.get(1).type);
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.BALL, level.objects.get(2).type);
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.ICE_BLOCK, level.objects.get(3).type);
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, level.objects.get(4).type);
|
||||
assertEquals(3, level.objects.get(4).mass);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void weightedObjectRejectsNonPositiveMassWithItsJsonPath() {
|
||||
String object = "\"objects\":[{\"type\":\"BOX\","
|
||||
+ "\"box\":{\"x\":300,\"y\":800,\"width\":80,\"height\":80},\"mass\":0}],";
|
||||
String json = validLevelJson(1).replace("\"platforms\":", object + "\"platforms\":");
|
||||
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(json), "massless.json");
|
||||
fail("A pressure object with no mass must fail closed");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains(
|
||||
"massless.json.objects[0].mass"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("must be positive"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitLevelIdentityAndCollectibleLoadFromData() {
|
||||
String json = validLevelJson(1)
|
||||
.replace("\"number\":1,", "\"number\":1,"
|
||||
+ "\"id\":\"foundations.hidden-cache\","
|
||||
+ "\"worldId\":\"foundations\",")
|
||||
.replace("\"targets\":", "\"collectible\":{\"x\":840,\"y\":700,"
|
||||
+ "\"width\":52,\"height\":52},\"targets\":");
|
||||
|
||||
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "challenge.json");
|
||||
|
||||
assertEquals("foundations.hidden-cache", level.levelId);
|
||||
assertEquals("foundations", level.worldId);
|
||||
assertEquals(840f, level.collectible.x, 0f);
|
||||
assertEquals(52f, level.collectible.width, 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidExplicitLevelIdFailsWithSourcePath() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"number\":1,", "\"number\":1,\"id\":\"Not Stable!\",");
|
||||
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(json), "invalid-id.json");
|
||||
fail("Unstable level IDs must fail closed");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("invalid-id.json.id"));
|
||||
}
|
||||
}
|
||||
|
||||
static String validLevelJson(int formatVersion) {
|
||||
return "{"
|
||||
+ "\"formatVersion\":" + formatVersion + ","
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 LevelPageTest {
|
||||
@Test
|
||||
public void eighteenLevelsUseTwoBoundedTenCardPages() {
|
||||
LevelPage page = new LevelPage(18, 10);
|
||||
|
||||
assertEquals(2, page.pageCount());
|
||||
assertEquals(0, page.firstIndex());
|
||||
assertEquals(10, page.endExclusive());
|
||||
assertFalse(page.hasPrevious());
|
||||
assertTrue(page.hasNext());
|
||||
|
||||
page.next();
|
||||
assertEquals(10, page.firstIndex());
|
||||
assertEquals(18, page.endExclusive());
|
||||
assertTrue(page.hasPrevious());
|
||||
assertFalse(page.hasNext());
|
||||
|
||||
page.next();
|
||||
assertEquals(1, page.pageIndex());
|
||||
page.selectIndex(3);
|
||||
assertEquals(0, page.pageIndex());
|
||||
page.selectIndex(15);
|
||||
assertEquals(1, page.pageIndex());
|
||||
page.selectIndex(16);
|
||||
assertEquals(1, page.pageIndex());
|
||||
page.selectIndex(17);
|
||||
assertEquals(1, page.pageIndex());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class PerformanceMonitorTest {
|
||||
@Test
|
||||
public void rollingP95AndComplexityAreComparedWithPublishedBudgets() {
|
||||
PerformanceMonitor monitor = new PerformanceMonitor(240);
|
||||
for (int frame = 0; frame < 240; frame++) {
|
||||
monitor.recordFrame(16f, 3f, 7f);
|
||||
monitor.recordComplexity(6, 180, 900, 48L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
PerformanceMonitor.Snapshot healthy = monitor.snapshot();
|
||||
assertEquals(240, healthy.sampleCount());
|
||||
assertEquals(16f, healthy.p95FrameIntervalMs(), 0f);
|
||||
assertTrue(healthy.within(PerformanceBudget.release()));
|
||||
assertEquals(6, healthy.maximumBodies());
|
||||
|
||||
for (int frame = 0; frame < 24; frame++) {
|
||||
monitor.recordFrame(75f, 18f, 30f);
|
||||
monitor.recordComplexity(17, 4_000, 3_000, 140L * 1024L * 1024L);
|
||||
}
|
||||
PerformanceMonitor.Snapshot overloaded = monitor.snapshot();
|
||||
assertFalse(overloaded.within(PerformanceBudget.release()));
|
||||
assertTrue(overloaded.failureSummary(PerformanceBudget.release()).contains("frameP95"));
|
||||
assertTrue(overloaded.failureSummary(PerformanceBudget.release()).contains("bodies"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public final class PhysicalObjectStackResolverTest {
|
||||
@Test
|
||||
public void fallingObjectSettlesOnTheHighestCrossedObject() {
|
||||
PhysicalObjectState lower = object(650f, 800f, 160f, 80f);
|
||||
PhysicalObjectState middle = object(690f, 721f, 140f, 80f)
|
||||
.velocity(0f, 60f);
|
||||
PhysicalObjectState distant = object(1_200f, 700f, 100f, 100f);
|
||||
|
||||
boolean settled = PhysicalObjectStackResolver.settleFalling(
|
||||
middle, 720f, Arrays.asList(lower, middle, distant));
|
||||
|
||||
assertTrue(settled);
|
||||
assertEquals(720f, middle.weightedY(), 0f);
|
||||
assertEquals(0f, middle.velocityY(), 0f);
|
||||
assertTrue(middle.grounded());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void risingOrNonCrossingObjectIsNotTeleportedOntoAStack() {
|
||||
PhysicalObjectState lower = object(650f, 800f, 160f, 80f);
|
||||
PhysicalObjectState rising = object(690f, 700f, 120f, 80f)
|
||||
.velocity(0f, -40f);
|
||||
PhysicalObjectState beside = object(300f, 721f, 100f, 80f)
|
||||
.velocity(0f, 60f);
|
||||
|
||||
assertFalse(PhysicalObjectStackResolver.settleFalling(
|
||||
rising, 704f, Arrays.asList(lower, rising)));
|
||||
assertFalse(PhysicalObjectStackResolver.settleFalling(
|
||||
beside, 720f, Arrays.asList(lower, beside)));
|
||||
}
|
||||
|
||||
private static PhysicalObjectState object(float x, float y, float width, float height) {
|
||||
return new PhysicalObjectState(new LevelDefinition.PhysicalObject(
|
||||
LevelDefinition.PhysicalObjectType.BOX,
|
||||
new LevelDefinition.Box(x, y, width, height), 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class PhysicalObjectStateTest {
|
||||
@Test
|
||||
public void authoredObjectPublishesStableMassGeometryAndRuntimeState() {
|
||||
LevelDefinition.PhysicalObject definition = new LevelDefinition.PhysicalObject(
|
||||
LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
|
||||
new LevelDefinition.Box(420f, 760f, 80f, 120f), 3);
|
||||
|
||||
PhysicalObjectState state = new PhysicalObjectState(definition);
|
||||
state.velocity(120f, -30f).grounded(true).supportIndex(2);
|
||||
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, state.type());
|
||||
assertEquals(420f, state.weightedX(), 0f);
|
||||
assertEquals(760f, state.weightedY(), 0f);
|
||||
assertEquals(80f, state.weightedWidth(), 0f);
|
||||
assertEquals(120f, state.weightedHeight(), 0f);
|
||||
assertEquals(3, state.pressureMass());
|
||||
assertTrue(state.activatesPressure());
|
||||
assertEquals(120f, state.velocityX(), 0f);
|
||||
assertEquals(-30f, state.velocityY(), 0f);
|
||||
assertTrue(state.grounded());
|
||||
assertEquals(2, state.supportIndex());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class PhysicsConfigTest {
|
||||
@Test
|
||||
public void releasePhysicsIsOneVersionedFiniteSourceOfTruth() {
|
||||
PhysicsConfig physics = PhysicsConfig.release();
|
||||
|
||||
assertEquals(1, physics.version());
|
||||
assertEquals(1f / 60f, physics.fixedStepSeconds(), 0f);
|
||||
assertEquals(1_800f, physics.gravity(), 0f);
|
||||
assertEquals(370f, physics.runSpeed(), 0f);
|
||||
assertEquals(880f, physics.jumpSpeed(), 0f);
|
||||
assertEquals(0.13f, physics.coyoteSeconds(), 0f);
|
||||
assertEquals(0.14f, physics.jumpBufferSeconds(), 0f);
|
||||
assertEquals(0.78f, physics.respawnSeconds(), 0f);
|
||||
assertEquals(0.10f, physics.turnSeconds(), 0f);
|
||||
assertEquals(0.12f, physics.landSeconds(), 0f);
|
||||
assertEquals(0.24f, physics.interactSeconds(), 0f);
|
||||
assertEquals(0.32f, physics.stunnedSeconds(), 0f);
|
||||
assertTrue(physics.maximumBodies() >= 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class PhysicsWaveOneContentTest {
|
||||
@Test
|
||||
public void firstPhysicsWaveMatchesTheExecutableMatrix() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertEquals(34, levels.size());
|
||||
|
||||
LevelDefinition mass = levels.get(30);
|
||||
assertIdentity(mass, 31, "physics.mass-basics", "Weight Class", 0, 45);
|
||||
assertFalse(mass.objects.isEmpty());
|
||||
assertTrue(mass.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
assertFalse(mass.doors.isEmpty());
|
||||
|
||||
LevelDefinition stack = levels.get(31);
|
||||
assertIdentity(stack, 32, "physics.stack-order", "Stack Order", 0, 50);
|
||||
assertTrue(stack.objects.size() >= 3);
|
||||
assertTrue("The authored staircase must contain an object supported by another",
|
||||
hasAuthoredObjectStack(stack));
|
||||
|
||||
LevelDefinition launch = levels.get(32);
|
||||
assertIdentity(launch, 33, "physics.launch-window", "Launch Window", 1, 55);
|
||||
assertTrue(launch.containsMechanic(LevelDefinition.HazardType.SAW));
|
||||
assertTrue(launch.hasMovingPlatform());
|
||||
|
||||
LevelDefinition crusher = levels.get(33);
|
||||
assertIdentity(crusher, 34, "physics.crusher-rhythm", "Press Cycle", 1, 55);
|
||||
assertTrue(crusher.containsMechanic(LevelDefinition.HazardType.CRUSHER));
|
||||
assertTrue(crusher.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
}
|
||||
|
||||
private static boolean hasAuthoredObjectStack(LevelDefinition level) {
|
||||
for (LevelDefinition.PhysicalObject upper : level.objects) {
|
||||
for (LevelDefinition.PhysicalObject lower : level.objects) {
|
||||
if (upper == lower) continue;
|
||||
boolean horizontalOverlap = upper.box.x < lower.box.right()
|
||||
&& upper.box.right() > lower.box.x;
|
||||
if (horizontalOverlap
|
||||
&& Math.abs(upper.box.bottom() - lower.box.y) <= 0.01f) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void assertIdentity(LevelDefinition level, int number, String id, String title,
|
||||
int targetDeaths, int targetSeconds) {
|
||||
assertEquals(number, level.number);
|
||||
assertEquals(id, level.levelId);
|
||||
assertEquals("physics", level.worldId);
|
||||
assertEquals(title, level.title);
|
||||
assertEquals(targetDeaths, level.targetDeaths);
|
||||
assertEquals(targetSeconds, level.targetSeconds);
|
||||
assertNotNull(level.collectible);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class PuzzleSystemTest {
|
||||
@Test
|
||||
public void toggleChannelChangesOnlyOnRisingEdgesAndPublishesEvents() {
|
||||
PuzzleSystem system = new PuzzleSystem();
|
||||
PuzzleSystem.Input held = PuzzleSystem.Input.toggle("lever-1", "gate", true);
|
||||
|
||||
system.update(0.016f, Collections.singletonList(held));
|
||||
assertTrue(system.signalActive("gate"));
|
||||
assertEquals(PuzzleSystem.Event.Kind.SIGNAL_ACTIVATED,
|
||||
system.events().get(0).kind());
|
||||
assertEquals("gate", system.events().get(0).channelId());
|
||||
|
||||
system.update(0.016f, Collections.singletonList(held));
|
||||
assertTrue(system.signalActive("gate"));
|
||||
assertTrue(system.events().isEmpty());
|
||||
|
||||
system.update(0.016f, Collections.singletonList(
|
||||
PuzzleSystem.Input.toggle("lever-1", "gate", false)));
|
||||
system.update(0.016f, Collections.singletonList(held));
|
||||
assertFalse(system.signalActive("gate"));
|
||||
assertEquals(PuzzleSystem.Event.Kind.SIGNAL_DEACTIVATED,
|
||||
system.events().get(0).kind());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pressureChannelAggregatesEveryActiveSourceWithoutFlicker() {
|
||||
PuzzleSystem system = new PuzzleSystem();
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate-left", "weight", true),
|
||||
PuzzleSystem.Input.pressure("plate-right", "weight", false)));
|
||||
assertTrue(system.signalActive("weight"));
|
||||
assertEquals(1, system.events().size());
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate-left", "weight", false),
|
||||
PuzzleSystem.Input.pressure("plate-right", "weight", true)));
|
||||
assertTrue(system.signalActive("weight"));
|
||||
assertTrue(system.events().isEmpty());
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate-left", "weight", false),
|
||||
PuzzleSystem.Input.pressure("plate-right", "weight", false)));
|
||||
assertFalse(system.signalActive("weight"));
|
||||
assertEquals(PuzzleSystem.Event.Kind.SIGNAL_DEACTIVATED,
|
||||
system.events().get(0).kind());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedChannelStartsOnRisingEdgeExpiresAndCanBeRearmed() {
|
||||
PuzzleSystem system = new PuzzleSystem();
|
||||
|
||||
system.update(0f, Collections.singletonList(
|
||||
PuzzleSystem.Input.timed("clock-switch", "clock", true, 0.5f)));
|
||||
assertTrue(system.signalActive("clock"));
|
||||
assertEquals(0.5f, system.remainingSeconds("clock"), 0f);
|
||||
|
||||
system.update(0.2f, Collections.singletonList(
|
||||
PuzzleSystem.Input.timed("clock-switch", "clock", true, 0.5f)));
|
||||
assertEquals(0.3f, system.remainingSeconds("clock"), 0.0001f);
|
||||
assertTrue(system.events().isEmpty());
|
||||
|
||||
system.update(0.31f, Collections.singletonList(
|
||||
PuzzleSystem.Input.timed("clock-switch", "clock", false, 0.5f)));
|
||||
assertFalse(system.signalActive("clock"));
|
||||
assertEquals(PuzzleSystem.Event.Kind.SIGNAL_DEACTIVATED,
|
||||
system.events().get(0).kind());
|
||||
|
||||
system.update(0f, Collections.singletonList(
|
||||
PuzzleSystem.Input.timed("clock-switch", "clock", true, 0.5f)));
|
||||
assertTrue(system.signalActive("clock"));
|
||||
assertEquals(0.5f, system.remainingSeconds("clock"), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void andDoorPublishesExplicitOpenAndCloseTransitions() {
|
||||
DoorController exit = new DoorController("exit",
|
||||
Arrays.asList("weight", "power"));
|
||||
PuzzleSystem system = new PuzzleSystem(Collections.singletonList(exit));
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate", "weight", true),
|
||||
PuzzleSystem.Input.relay("conductor", "power", false)));
|
||||
assertEquals(DoorController.State.CLOSED, exit.state());
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate", "weight", true),
|
||||
PuzzleSystem.Input.relay("conductor", "power", true)));
|
||||
assertEquals(DoorController.State.OPEN, exit.state());
|
||||
assertTrue(hasEvent(system, PuzzleSystem.Event.Kind.DOOR_OPENED, "exit"));
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate", "weight", false),
|
||||
PuzzleSystem.Input.relay("conductor", "power", true)));
|
||||
assertEquals(DoorController.State.CLOSED, exit.state());
|
||||
assertTrue(hasEvent(system, PuzzleSystem.Event.Kind.DOOR_CLOSED, "exit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resetAndRestoreOwnAnImmutablePuzzleStateSnapshot() {
|
||||
DoorController exit = new DoorController("exit", Arrays.asList("gate", "clock"));
|
||||
PuzzleSystem source = new PuzzleSystem(Collections.singletonList(exit));
|
||||
source.update(0f, Arrays.asList(
|
||||
PuzzleSystem.Input.toggle("lever", "gate", true),
|
||||
PuzzleSystem.Input.timed("timer", "clock", true, 2f)));
|
||||
PuzzleSystem.State saved = source.snapshot();
|
||||
|
||||
source.reset();
|
||||
assertFalse(source.signalActive("gate"));
|
||||
assertFalse(source.signalActive("clock"));
|
||||
assertEquals(DoorController.State.CLOSED, exit.state());
|
||||
|
||||
DoorController restoredDoor = new DoorController("exit",
|
||||
Arrays.asList("gate", "clock"));
|
||||
PuzzleSystem restored = new PuzzleSystem(Collections.singletonList(restoredDoor));
|
||||
restored.restore(saved, Collections.singletonMap("lever", true));
|
||||
|
||||
assertTrue(restored.signalActive("gate"));
|
||||
assertEquals(2f, restored.remainingSeconds("clock"), 0f);
|
||||
assertEquals(DoorController.State.OPEN, restoredDoor.state());
|
||||
restored.update(0f, Collections.singletonList(
|
||||
PuzzleSystem.Input.toggle("lever", "gate", true)));
|
||||
assertTrue("Held restored contact must not toggle again",
|
||||
restored.signalActive("gate"));
|
||||
assertEquals(Boolean.TRUE, saved.toggleSignals().get("gate"));
|
||||
assertEquals(2f, saved.timedSignals().get("clock"), 0f);
|
||||
try {
|
||||
saved.toggleSignals().put("other", true);
|
||||
throw new AssertionError("Puzzle state maps must be immutable");
|
||||
} catch (UnsupportedOperationException expected) {
|
||||
// Contract proven.
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasEvent(PuzzleSystem system, PuzzleSystem.Event.Kind kind,
|
||||
String targetId) {
|
||||
for (PuzzleSystem.Event event : system.events()) {
|
||||
if (event.kind() == kind && targetId.equals(event.targetId())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class VersionedSessionCodecTest {
|
||||
@Test
|
||||
public void currentSnapshotRoundTripsEveryPuzzleCriticalField() {
|
||||
GameSessionSnapshot original = sampleSnapshot();
|
||||
|
||||
VersionedSessionCodec.DecodeResult decoded =
|
||||
VersionedSessionCodec.decode(VersionedSessionCodec.encode(original));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.RESTORED, decoded.status());
|
||||
GameSessionSnapshot restored = decoded.snapshot();
|
||||
assertEquals(5, restored.levelNumber());
|
||||
assertEquals(12.75f, restored.elapsedSeconds(), 0f);
|
||||
assertEquals(2, restored.deaths());
|
||||
assertEquals(640f, restored.player().x(), 0f);
|
||||
assertFalse(restored.player().alive());
|
||||
assertEquals(2, restored.bodies().size());
|
||||
assertTrue(restored.bodies().get(0).pinned());
|
||||
assertTrue(restored.bodies().get(1).conductive());
|
||||
assertTrue(restored.bodies().get(0).crushed());
|
||||
assertTrue(restored.bodies().get(1).burned());
|
||||
assertEquals(2, restored.objects().size());
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.BOX,
|
||||
restored.objects().get(0).type());
|
||||
assertEquals(3, restored.objects().get(1).mass());
|
||||
assertTrue(restored.collectibleFound());
|
||||
assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes());
|
||||
assertTrue(restored.toggleSignals().get("gate"));
|
||||
assertEquals(2.25f, restored.timedSignals().get("clock"), 0f);
|
||||
assertTrue(restored.previousSwitchContacts().get("gate@420.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyV1MigratesWithSafeEmptyEdgeContacts() {
|
||||
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
|
||||
VersionedSessionCodec.encodeLegacyV1(sampleSnapshot()));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
|
||||
assertEquals(VersionedSessionCodec.CURRENT_VERSION,
|
||||
decoded.snapshot().saveVersion());
|
||||
assertTrue(decoded.snapshot().previousSwitchContacts().isEmpty());
|
||||
assertEquals(2, decoded.snapshot().bodies().size());
|
||||
assertFalse(decoded.snapshot().bodies().get(0).crushed());
|
||||
assertFalse(decoded.snapshot().bodies().get(1).burned());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyV2MigratesWithSafeDefaultCorpseStates() {
|
||||
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
|
||||
VersionedSessionCodec.encodeLegacyV2(sampleSnapshot()));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
|
||||
assertTrue(decoded.snapshot().previousSwitchContacts().get("gate@420.0"));
|
||||
assertFalse(decoded.snapshot().bodies().get(0).crushed());
|
||||
assertFalse(decoded.snapshot().bodies().get(1).burned());
|
||||
assertTrue(decoded.snapshot().objects().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyV3MigratesWithSafeEmptyPhysicalObjectRuntime() {
|
||||
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
|
||||
VersionedSessionCodec.encodeLegacyV3(sampleSnapshot()));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
|
||||
assertTrue(decoded.snapshot().bodies().get(0).crushed());
|
||||
assertTrue(decoded.snapshot().bodies().get(1).burned());
|
||||
assertTrue(decoded.snapshot().objects().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyV4MigratesWithCollectibleNotInvented() {
|
||||
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
|
||||
VersionedSessionCodec.encodeLegacyV4(sampleSnapshot()));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
|
||||
assertEquals(2, decoded.snapshot().objects().size());
|
||||
assertFalse(decoded.snapshot().collectibleFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void corruptAndFutureSnapshotsFailClosed() {
|
||||
VersionedSessionCodec.DecodeResult corrupt =
|
||||
VersionedSessionCodec.decode(new byte[] {1, 2, 3});
|
||||
VersionedSessionCodec.DecodeResult future =
|
||||
VersionedSessionCodec.decode(VersionedSessionCodec.encodeVersionForTest(99));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.CORRUPT, corrupt.status());
|
||||
assertNull(corrupt.snapshot());
|
||||
assertEquals(VersionedSessionCodec.Status.UNSUPPORTED, future.status());
|
||||
assertNull(future.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void productionTeachingLevelSnapshotRoundTrips() {
|
||||
GameSessionSnapshot snapshot = GameSessionSnapshot.builder(14)
|
||||
.elapsedSeconds(3f)
|
||||
.player(new GameSessionSnapshot.PlayerData(
|
||||
130f, 760f, 0f, 0f, true, true,
|
||||
0f, 0f, -1, 1))
|
||||
.build();
|
||||
|
||||
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
|
||||
VersionedSessionCodec.encode(snapshot));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.RESTORED, decoded.status());
|
||||
assertEquals(14, decoded.snapshot().levelNumber());
|
||||
}
|
||||
|
||||
private static GameSessionSnapshot sampleSnapshot() {
|
||||
return GameSessionSnapshot.builder(5)
|
||||
.elapsedSeconds(12.75f)
|
||||
.deaths(2)
|
||||
.collectibleFound(true)
|
||||
.respawnSeconds(0.4f)
|
||||
.player(new GameSessionSnapshot.PlayerData(
|
||||
640f, 780f, 120f, -40f, false, false,
|
||||
0.1f, 0.08f, -1, -1))
|
||||
.addBody(new GameSessionSnapshot.BodyData(
|
||||
500f, 826f, 72f, 54f, 0f, 0f,
|
||||
false, false, true, true, true, false,
|
||||
-1, "CRUSH"))
|
||||
.addBody(new GameSessionSnapshot.BodyData(
|
||||
720f, 826f, 72f, 54f, 0f, 0f,
|
||||
false, true, true, true, false, true,
|
||||
-1, "FIRE"))
|
||||
.addObject(new GameSessionSnapshot.ObjectData(
|
||||
LevelDefinition.PhysicalObjectType.BOX,
|
||||
300f, 800f, 80f, 80f, 20f, 0f, 1, true, -1))
|
||||
.addObject(new GameSessionSnapshot.ObjectData(
|
||||
LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
|
||||
700f, 780f, 80f, 100f, 0f, 0f, 3, true, 1))
|
||||
.platformMotionTimes(Arrays.asList(1.25f, 3.5f))
|
||||
.toggleSignal("gate", true)
|
||||
.timedSignal("clock", 2.25f)
|
||||
.previousSwitchContact("gate@420.0", true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class VolunteerRigTest {
|
||||
@Test
|
||||
public void skinsReuseTheSameNamedSkeletonAndOnlyChangePalette() {
|
||||
VolunteerRig.Pose brass = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.IDLE, 0f, 1);
|
||||
VolunteerRig.Pose verdigris = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.VERDIGRIS_KEEPER, VolunteerRig.Motion.IDLE, 0f, 1);
|
||||
|
||||
assertEquals(VolunteerRig.Joint.values().length, brass.jointCount());
|
||||
for (VolunteerRig.Joint joint : VolunteerRig.Joint.values()) {
|
||||
assertEquals(brass.x(joint), verdigris.x(joint), 0f);
|
||||
assertEquals(brass.y(joint), verdigris.y(joint), 0f);
|
||||
assertTrue(brass.x(joint) >= 0f && brass.x(joint) <= 1f);
|
||||
assertTrue(brass.y(joint) >= 0f && brass.y(joint) <= 1f);
|
||||
}
|
||||
assertNotEquals(brass.palette().shell(), verdigris.palette().shell());
|
||||
assertNotEquals(brass.palette().cloth(), verdigris.palette().cloth());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void motionChangesLimbsWithoutChangingCollisionOrRootContract() {
|
||||
VolunteerRig.Pose idle = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.EMBER_COURIER, VolunteerRig.Motion.IDLE, 0f, 1);
|
||||
VolunteerRig.Pose run = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.EMBER_COURIER, VolunteerRig.Motion.RUN, 0.25f, 1);
|
||||
VolunteerRig.Pose mirrored = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.EMBER_COURIER, VolunteerRig.Motion.RUN, 0.25f, -1);
|
||||
|
||||
assertEquals(62f, idle.collisionWidth(), 0f);
|
||||
assertEquals(100f, idle.collisionHeight(), 0f);
|
||||
assertEquals(idle.x(VolunteerRig.Joint.ROOT), run.x(VolunteerRig.Joint.ROOT), 0f);
|
||||
assertNotEquals(idle.x(VolunteerRig.Joint.LEFT_HAND),
|
||||
run.x(VolunteerRig.Joint.LEFT_HAND), 0.001f);
|
||||
assertEquals(1f - run.x(VolunteerRig.Joint.LEFT_HAND),
|
||||
mirrored.x(VolunteerRig.Joint.LEFT_HAND), 0.001f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void gameplayStatesHaveNonColorVisualSignals() {
|
||||
VolunteerRig.Pose dead = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.DEAD, 0f, 1);
|
||||
VolunteerRig.Pose frozen = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.FROZEN, 0f, 1);
|
||||
VolunteerRig.Pose conductive = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.CONDUCTIVE, 0f, 1);
|
||||
VolunteerRig.Pose pinned = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.PINNED, 0f, 1);
|
||||
VolunteerRig.Pose respawn = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.RESPAWN, 0.5f, 1);
|
||||
|
||||
assertFalse(dead.visorLit());
|
||||
assertTrue(frozen.frozenShell());
|
||||
assertTrue(conductive.conductiveArcs());
|
||||
assertTrue(pinned.anchorBolt());
|
||||
assertEquals(0.5f, respawn.assemblyProgress(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void gameplayMotionIsDerivedFromGroundVelocityAndBodyState() {
|
||||
assertEquals(VolunteerRig.Motion.IDLE,
|
||||
VolunteerRig.aliveMotion(true, 0f, 0f));
|
||||
assertEquals(VolunteerRig.Motion.RUN,
|
||||
VolunteerRig.aliveMotion(true, 370f, 0f));
|
||||
assertEquals(VolunteerRig.Motion.JUMP,
|
||||
VolunteerRig.aliveMotion(false, 120f, -400f));
|
||||
assertEquals(VolunteerRig.Motion.FALL,
|
||||
VolunteerRig.aliveMotion(false, 120f, 200f));
|
||||
|
||||
assertEquals(VolunteerRig.Motion.FROZEN,
|
||||
VolunteerRig.bodyMotion(true, true, true));
|
||||
assertEquals(VolunteerRig.Motion.CONDUCTIVE,
|
||||
VolunteerRig.bodyMotion(false, true, true));
|
||||
assertEquals(VolunteerRig.Motion.PINNED,
|
||||
VolunteerRig.bodyMotion(false, false, true));
|
||||
assertEquals(VolunteerRig.Motion.DEAD,
|
||||
VolunteerRig.bodyMotion(false, false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyLivingCharacterStateHasAnExplicitAnimationMotion() {
|
||||
for (CharacterStateMachine.State state : CharacterStateMachine.State.values()) {
|
||||
assertEquals(state.name(), VolunteerRig.motionFor(state).name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void transientCharacterStatesHaveNonColorPoseSignals() {
|
||||
VolunteerRig.Skin skin = VolunteerRig.Skin.BRASS_SCOUT;
|
||||
VolunteerRig.Pose idle = VolunteerRig.pose(skin, VolunteerRig.Motion.IDLE, 0.25f, 1);
|
||||
VolunteerRig.Pose turn = VolunteerRig.pose(skin, VolunteerRig.Motion.TURN, 0.25f, 1);
|
||||
VolunteerRig.Pose land = VolunteerRig.pose(skin, VolunteerRig.Motion.LAND, 0.25f, 1);
|
||||
VolunteerRig.Pose interact = VolunteerRig.pose(
|
||||
skin, VolunteerRig.Motion.INTERACT, 0.25f, 1);
|
||||
VolunteerRig.Pose stunned = VolunteerRig.pose(
|
||||
skin, VolunteerRig.Motion.STUNNED, 0.25f, 1);
|
||||
|
||||
assertNotEquals(idle.x(VolunteerRig.Joint.LEFT_HAND),
|
||||
turn.x(VolunteerRig.Joint.LEFT_HAND), 0.001f);
|
||||
assertNotEquals(idle.y(VolunteerRig.Joint.ROOT),
|
||||
land.y(VolunteerRig.Joint.ROOT), 0.001f);
|
||||
assertNotEquals(idle.x(VolunteerRig.Joint.RIGHT_HAND),
|
||||
interact.x(VolunteerRig.Joint.RIGHT_HAND), 0.001f);
|
||||
assertNotEquals(idle.y(VolunteerRig.Joint.LEFT_HAND),
|
||||
stunned.y(VolunteerRig.Joint.LEFT_HAND), 0.001f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class WeightedPressureResolverTest {
|
||||
@Test
|
||||
public void plateUsesOneMassContractForPlayerCorpseAndPhysicalObject() {
|
||||
LevelDefinition.Box plate = new LevelDefinition.Box(100f, 200f, 180f, 20f);
|
||||
IWeightedObject player = item(110f, 150f, 62f, 100f, 1, true);
|
||||
IWeightedObject corpse = item(170f, 166f, 72f, 54f, 1, true);
|
||||
IWeightedObject counterweight = item(230f, 140f, 70f, 80f, 3, true);
|
||||
|
||||
assertFalse(PressureMassResolver.active(
|
||||
plate, 6, Arrays.asList(player, corpse, counterweight)));
|
||||
assertTrue(PressureMassResolver.active(
|
||||
plate, 5, Arrays.asList(player, corpse, counterweight)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void inactiveAndNonIntersectingObjectsNeverContributeMass() {
|
||||
LevelDefinition.Box plate = new LevelDefinition.Box(100f, 200f, 180f, 20f);
|
||||
IWeightedObject burnedCorpse = item(120f, 166f, 72f, 54f, 5, false);
|
||||
IWeightedObject distantRock = item(500f, 160f, 60f, 60f, 5, true);
|
||||
|
||||
assertFalse(PressureMassResolver.active(
|
||||
plate, 1, Arrays.asList(burnedCorpse, distantRock)));
|
||||
}
|
||||
|
||||
private static IWeightedObject item(float x, float y, float width, float height,
|
||||
int mass, boolean active) {
|
||||
return new IWeightedObject() {
|
||||
@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 active; }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,10 @@ master tracks origin/master
|
||||
трактовать как точный процент готовности, потому что в базе смешаны эпики,
|
||||
features и задачи разного размера.
|
||||
|
||||
Текущий primary gate — Stage 5 и `goon-game-milestone-vslice`. Этапы 0–4
|
||||
не содержат незакрытых primary-задач. Stage 5 включает внешний
|
||||
`goon-game-human-playtest`, оставшийся performance/repetition criterion
|
||||
`goon-game-regression-10`, balance comprehension gate, art/audio, lifecycle,
|
||||
save migration и performance budget. Production content 55–70 уровней имеет
|
||||
primary Stage 6.
|
||||
Этапы 0–5 не содержат незакрытых primary-задач. Текущий primary gate — Stage 6:
|
||||
production content 55–70 уровней, четыре мира, расширение mechanics/challenges и
|
||||
production progression. Ограничения внешнего Stage 5 evidence зафиксированы в
|
||||
`STAGE5_HUMAN_PLAYTEST_RESULT.md`.
|
||||
|
||||
## Ежедневные команды
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user