Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243da9185e | ||
|
|
f53e068cb1 | ||
|
|
4d8aa28f0a | ||
|
|
b31c2ea720 | ||
|
|
4869649b47 | ||
|
|
3de8d45039 | ||
|
|
259fcd316b | ||
|
|
19bb64edfb | ||
|
|
dc2ebcbf8c | ||
|
|
cc7f487cf7 | ||
|
|
cc3b5902cc | ||
|
|
62ee6877f9 | ||
|
|
0943caa3cc |
@@ -44,3 +44,6 @@
|
||||
{"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."}}
|
||||
|
||||
+16
-16
@@ -1,9 +1,9 @@
|
||||
{"_type":"issue","id":"goon-game-level10-sliding-corpse","title":"Остановить скольжение тела на плите уровня 10","description":"Брошенное пилой тело уровня 10 может продолжать скользить по полу и съезжать с PRESSURE lift, из-за чего движущаяся платформа теряет сигнал и intended route становится нестабильным.","acceptance_criteria":"После доставки тело стабильно остаётся на PRESSURE lift без ручного тайминга; сигнал lift не пропадает при длительном ожидании; уровень 10 проходит normal и delayed-input маршруты по 3 раза без дополнительных смертей.","notes":"2026-08-18: focused RED showed firstCorpseX drift 791.4791 -\u003e 866.5033 in 750 ms. Grounded bodies now settle fully inside PRESSURE, stop, and pin. GREEN: position stable, lift powered after 6 s, exactly 1 death/body; level 10 delayed run and 3 consecutive mastery runs passed; final unfiltered matrix 16/16.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T08:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:53Z","started_at":"2026-08-18T08:40:02Z","closed_at":"2026-08-18T10:41:47Z","close_reason":"Acceptance met: delivered body remains on lift plate, signal stays active, normal 3x and delayed-input level 10 routes pass.","metadata":{"stages":2,"primary_stage":2},"labels":["bug","current","level10","physics","primary-stage-2","stage-2"],"dependencies":[{"issue_id":"goon-game-level10-sliding-corpse","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T11:40:01Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level6-freezer-overlap","title":"Исправить повторную смерть на frozen corpse уровня 6","description":"Frozen corpse уровня 6 фиксируется на y=776..880, а FREEZER остаётся y=750..880; игрок на теле занимает y=676..776 и пересекает hazard на 26 px, поэтому intended stepping-stone route вызывает немедленную вторую смерть.","acceptance_criteria":"Игрок может стоять и прыгнуть с предусмотренного frozen corpse без повторной FREEZE-смерти; вход в FREEZER с пола всё ещё убивает; intended route уровня 6 проходит три раза подряд production-input ботом.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:46:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:53Z","started_at":"2026-08-17T22:46:52Z","closed_at":"2026-08-18T01:30:37Z","close_reason":"FREEZER больше не убивает игрока на frozen stepping stone; floor lethality сохранена; уровень 6 проходит 3/3.","metadata":{"stages":2,"primary_stage":2},"labels":["bug","current","freeze","level6","primary-stage-2","stage-2"],"dependencies":[{"issue_id":"goon-game-level6-freezer-overlap","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:46:51Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level8-softlock","title":"Устранить softlock таймера уровня 8","description":"Если clock истекает после пересечения PIT, дверь закрывается, платформа останавливается, а переключатель остаётся слева: игрок может оказаться заперт справа до restart.","acceptance_criteria":"Бот воспроизводит expiry после PIT; игрок всегда имеет путь к restart-free recovery или таймер/геометрия исключают ловушку; сценарий проходит 3 раза подряд.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:40Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:54Z","started_at":"2026-08-17T22:33:08Z","closed_at":"2026-08-18T01:30:42Z","close_reason":"Expiry справа от PIT воспроизводился; recovery relay и баланс таймера проверены intended 3/3 и recovery 3/3.","metadata":{"stages":2,"primary_stage":2},"labels":["bug","current","level8","primary-stage-2","softlock","stage-2"],"dependencies":[{"issue_id":"goon-game-level8-softlock","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:09:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-milestone-vslice","title":"Milestone: Vertical Slice","description":"8–10 polished уровней, Android controls, camera, audio, UI, save и 5–6 mechanics.","acceptance_criteria":"Внешний тестировщик проходит сборку без объяснений; 10/10 bots passed.","notes":"2026-08-19 engineering RC complete: art/audio/camera/save/lifecycle/determinism/performance tasks closed; clean 72 JVM tests and exact Android 16 35/35 in 493.521s; APK format/signature/install/cold-start verified. Formal milestone remains in_progress solely for goon-game-human-playtest and the linked external comprehension portion of goon-game-level-balance.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:31Z","started_at":"2026-08-17T21:41:29Z","metadata":{"stages":5,"primary_stage":5},"labels":["current","gate","milestone","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-android-lifecycle","type":"blocks","created_at":"2026-08-18T00:44:22Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-art-vslice","type":"blocks","created_at":"2026-08-18T00:44:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-audio-vslice","type":"blocks","created_at":"2026-08-18T00:44:19Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-controls-settings","type":"blocks","created_at":"2026-08-18T00:44:17Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:55Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-human-playtest","type":"blocks","created_at":"2026-08-18T00:44:16Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-menus-localization","type":"blocks","created_at":"2026-08-18T00:44:18Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:44:23Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:44:15Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-save-versioning","type":"blocks","created_at":"2026-08-18T00:44:21Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":9,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-milestone-vslice","title":"Milestone: Vertical Slice","description":"8–10 polished уровней, Android controls, camera, audio, UI, save и 5–6 mechanics.","acceptance_criteria":"Внешний тестировщик проходит сборку без объяснений; 10/10 bots passed.","notes":"2026-08-19 engineering RC complete: art/audio/camera/save/lifecycle/determinism/performance tasks closed; clean 72 JVM tests and exact Android 16 35/35 in 493.521s; APK format/signature/install/cold-start verified. Formal milestone remains in_progress solely for goon-game-human-playtest and the linked external comprehension portion of goon-game-level-balance.\n2026-08-19: завершение внешнего тестирования принято по прямому подтверждению владельца продукта; инженерные зависимости закрыты, ограничения доказательств документированы. Stage 5 принят, Stage 6 разблокирован.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:18:32Z","started_at":"2026-08-17T21:41:29Z","closed_at":"2026-08-18T23:18:32Z","close_reason":"Vertical Slice accepted: 10/10 automated, engineering gates green, external testing confirmed complete by product owner.","metadata":{"stages":5,"primary_stage":5},"labels":["current","gate","milestone","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-android-lifecycle","type":"blocks","created_at":"2026-08-18T00:44:22Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-art-vslice","type":"blocks","created_at":"2026-08-18T00:44:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-audio-vslice","type":"blocks","created_at":"2026-08-18T00:44:19Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-controls-settings","type":"blocks","created_at":"2026-08-18T00:44:17Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:55Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-human-playtest","type":"blocks","created_at":"2026-08-18T00:44:16Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-menus-localization","type":"blocks","created_at":"2026-08-18T00:44:18Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:44:23Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:44:15Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-save-versioning","type":"blocks","created_at":"2026-08-18T00:44:21Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":9,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-milestone-prototype","title":"Milestone: Prototype","description":"Character, death, corpse, respawn, spikes, plate, door, goal и restart без финального art.","acceptance_criteria":"Собраны минимум пять разных головоломок из базовых элементов.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:02Z","closed_at":"2026-08-17T21:41:27Z","close_reason":"Exit criteria прототипа подтверждены 10 greybox-уровнями.","metadata":{"stages":0,"primary_stage":0},"labels":["closed","milestone","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-milestone-prototype","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-human-playtest","title":"Внешний playtest vertical slice","description":"Две ручные сессии каждого уровня и тест без объяснений разработчика.","acceptance_criteria":"Нет blocker softlocks; наблюдения и решения зафиксированы.","notes":"2026-08-19 immutable candidate prepared: 0.8.0-stage5-vslice-debug, versionCode 9, APK SHA-256 8bb970c7385a48d8002907382067a5f8f87554a33ad7aaace9aa593194c8fed5. Protocol and required per-session record are in docs/STAGE5_HUMAN_PLAYTEST.md. Zero of 20 external sessions may be inferred from bot/developer testing.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:28Z","metadata":{"stages":5,"primary_stage":5},"labels":["primary-stage-5","qa","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:51Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-level-balance","type":"blocks","created_at":"2026-08-18T00:41:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-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}
|
||||
@@ -12,7 +12,7 @@
|
||||
{"_type":"issue","id":"goon-game-unit-tests","title":"Базовые unit tests и Android Lint","description":"В текущем проекте проходят 8 unit-тестов и lintDebug.","acceptance_criteria":"Чистая Gradle-сборка зелёная.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:02Z","closed_at":"2026-08-17T21:41:24Z","close_reason":"8 unit-тестов и lintDebug прошли.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0","tests"],"dependencies":[{"issue_id":"goon-game-unit-tests","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-android-lifecycle","title":"Android lifecycle и восстановление Activity","description":"Pause/background/resume/screen lock/low memory без потери или скрытого продолжения игры.","acceptance_criteria":"Инструментальные тесты подтверждают паузу и восстановление поддерживаемого состояния.","notes":"2026-08-18: обычный STARTED→RESUMED путь исправлен и доказан ActivityScenario на Android 16: видимая пауза, очищенный input, сохранены level/deaths/corpse/player/timer, после explicit Resume симуляция продолжает движение. Connected GREEN 1/1. Задача остаётся in_progress до screen lock и process-death/low-memory восстановления.\nStage 5 RC: background/resume clears held input and shows visible pause; Activity recreation restores level/player/bodies/timers/platform signals; focused and full Android gates GREEN. Broader API/device/low-memory matrix remains separately owned by Stage 7.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:26Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:01Z","started_at":"2026-08-17T21:52:46Z","closed_at":"2026-08-18T23:02:01Z","close_reason":"Supported lifecycle and Activity/process-like recreation state restore are proven for the Vertical Slice.","metadata":{"stages":5,"primary_stage":5},"labels":["android","current","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-android-lifecycle","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-android-apk","title":"Собираемый и устанавливаемый Android APK","description":"Debug APK versionCode 3 проверен на Android 16, ZIP и подписи валидны.","acceptance_criteria":"Установка и cold launch возвращают Success/Status ok.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:24Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:02Z","closed_at":"2026-08-17T21:41:23Z","close_reason":"APK versionCode 3 собран, подписан, установлен и запущен.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-android-apk","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level-balance","title":"Исправить стабильность и баланс уровней 1–10","description":"Устранить softlocks, нестабильные тела и чрезмерно точные прыжки, найденные ботами и ручным тестированием.","acceptance_criteria":"10/10 уровней проходят три раза подряд и понятны внешнему тестировщику.","notes":"2026-08-18: bot-driven balance fixes completed for level 6 freezer overlap, level 8 timer recovery/launch margin, and level 10 relay/lift boarding. Intended routes 1–10 pass 3x in one integrated matrix. Task remains in progress until two manual comprehension sessions per level and alternative-route decisions are recorded.\n2026-08-18 engineering Stage 2 candidate complete: level 10 sliding corpse fixed, clean restart covered, final exact APK matrix 16/16 in 479.619 s. External two-session comprehension gate and regression runtime/repetition task remain open; do not claim external playtest acceptance.\n2026-08-18 exact versionCode 5 full matrix also GREEN 16/16 in 572.496 s. Engineering artifact is ready; external comprehension sessions remain the only product-balance acceptance not executable by automation.\n2026-08-19 Stage 5 RC: exact unfiltered Android 16 matrix GREEN 35/35 in 493.521s; deterministic 10-level tapes replay 3x with matching signatures; level 8 recovery and level 10 body stability/delayed traversal GREEN. Engineering balance is complete. Task remains in_progress only because external comprehension requires 20 independent sessions; see docs/STAGE5_HUMAN_PLAYTEST.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:24Z","started_at":"2026-08-18T01:31:03Z","metadata":{"stages":"2,5","primary_stage":5},"labels":["balance","current","levels","primary-stage-5","stage-2","stage-5"],"dependencies":[{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level-route-risks","type":"blocks","created_at":"2026-08-18T01:10:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level10-sliding-corpse","type":"blocks","created_at":"2026-08-18T11:40:08Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level8-softlock","type":"blocks","created_at":"2026-08-18T01:10:14Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-solution-graphs","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level-balance","title":"Исправить стабильность и баланс уровней 1–10","description":"Устранить softlocks, нестабильные тела и чрезмерно точные прыжки, найденные ботами и ручным тестированием.","acceptance_criteria":"10/10 уровней проходят три раза подряд и понятны внешнему тестировщику.","notes":"2026-08-18: bot-driven balance fixes completed for level 6 freezer overlap, level 8 timer recovery/launch margin, and level 10 relay/lift boarding. Intended routes 1–10 pass 3x in one integrated matrix. Task remains in progress until two manual comprehension sessions per level and alternative-route decisions are recorded.\n2026-08-18 engineering Stage 2 candidate complete: level 10 sliding corpse fixed, clean restart covered, final exact APK matrix 16/16 in 479.619 s. External two-session comprehension gate and regression runtime/repetition task remain open; do not claim external playtest acceptance.\n2026-08-18 exact versionCode 5 full matrix also GREEN 16/16 in 572.496 s. Engineering artifact is ready; external comprehension sessions remain the only product-balance acceptance not executable by automation.\n2026-08-19 Stage 5 RC: exact unfiltered Android 16 matrix GREEN 35/35 in 493.521s; deterministic 10-level tapes replay 3x with matching signatures; level 8 recovery and level 10 body stability/delayed traversal GREEN. Engineering balance is complete. Task remains in_progress only because external comprehension requires 20 independent sessions; see docs/STAGE5_HUMAN_PLAYTEST.md.\n2026-08-19: владелец продукта подтвердил завершение внешнего тестирования и распорядился перейти дальше. Подтверждение принято как comprehension gate; сырые формы сессий и количественные метрики не приложены и не реконструируются. Автоматические 10/10 и stability gates остаются GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:18:30Z","started_at":"2026-08-18T01:31:03Z","closed_at":"2026-08-18T23:18:30Z","close_reason":"External comprehension gate accepted by direct product-owner confirmation; engineering and bot balance gates were already green.","metadata":{"stages":"2,5","primary_stage":5},"labels":["balance","current","levels","primary-stage-5","stage-2","stage-5"],"dependencies":[{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level-route-risks","type":"blocks","created_at":"2026-08-18T01:10:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level10-sliding-corpse","type":"blocks","created_at":"2026-08-18T11:40:08Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level8-softlock","type":"blocks","created_at":"2026-08-18T01:10:14Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-solution-graphs","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-solution-graphs","title":"Задокументировать solution graph уровней 1–10","description":"Для каждого текущего уровня записать обязательную цепочку причины и следствия и допустимые альтернативы.","acceptance_criteria":"В docs есть 10 solution graphs, синхронизированных с ботами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:54Z","started_at":"2026-08-17T21:52:46Z","closed_at":"2026-08-17T22:09:09Z","close_reason":"Документированы 10 source-backed solution graphs с механиками, смертями, checkpoints и рисками; только уровень 5 честно отмечен доказанным connected-тестом.","metadata":{"stages":2,"primary_stage":2},"labels":["current","design","levels","primary-stage-2","stage-2"],"dependencies":[{"issue_id":"goon-game-solution-graphs","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:19Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-levels-greybox","title":"Greybox vertical slice из 10 уровней","description":"Десять уровней демонстрируют основные механики и прогрессию.","acceptance_criteria":"Каталог содержит 10 последовательных уровней с goal и restart.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:52Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:03Z","closed_at":"2026-08-17T21:41:19Z","close_reason":"Созданы 10 последовательных greybox-уровней.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0","vertical-slice"],"dependencies":[{"issue_id":"goon-game-levels-greybox","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:17Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-physics-determinism","title":"Зафиксировать deterministic physics contract","description":"Вынести параметры физики в конфиг, добавить grounded/velocity diagnostics и воспроизводимый command replay.","acceptance_criteria":"Три одинаковых replay каждого уровня дают одинаковый итог и не расходятся по ключевым состояниям.","notes":"Stage 5 RC: PhysicsConfig v1 and fixed 1/60 step; exact command tapes for levels 1-10 replay three sequential passes with identical state signatures and death/body counts, each pass guarded under five minutes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:33Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:08Z","closed_at":"2026-08-18T23:02:08Z","close_reason":"Three identical replays for every level produce matching states and outcomes.","metadata":{"stages":5,"primary_stage":5},"labels":["current","physics","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-physics-determinism","depends_on_id":"goon-game-e03","type":"parent-child","created_at":"2026-08-18T00:43:08Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
@@ -23,7 +23,7 @@
|
||||
{"_type":"issue","id":"goon-game-e12","title":"EPIC-12 Android","description":"Lifecycle, устройства, aspect ratios, graphics profiles и сборки. Разделы 41, 44–46, 49.","acceptance_criteria":"Release build стабильно работает на целевой Android-матрице.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:08Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:47Z","metadata":{"stages":"0,3,5,7","primary_stage":7},"labels":["android","epic","primary-stage-7","stage-0","stage-3","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e12","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:55Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e07","title":"EPIC-07 Levels","description":"Solution graphs, greybox, прогрессия механик и производство 55–70 уровней. Разделы 19–25, 52–53, 66.","acceptance_criteria":"Каждый уровень имеет доказанное решение, DoD и место в content matrix.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:57Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:25Z","metadata":{"stages":"0,2,4,6","primary_stage":6},"labels":["content","epic","levels","primary-stage-6","stage-0","stage-2","stage-4","stage-6"],"dependencies":[{"issue_id":"goon-game-e07","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:49Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e03","title":"EPIC-03 Physics","description":"Предсказуемая гибридная физика и бюджеты активных объектов. Разделы 15, 41–43, 65.","acceptance_criteria":"Одинаковая последовательность ввода воспроизводимо приводит к одинаковому состоянию головоломки.","notes":"Stage 5 closed the deterministic physics contract: PhysicsConfig v1 and 10 exact command traces replayed 3x with identical signatures; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:55Z","closed_at":"2026-08-18T23:02:55Z","close_reason":"Physics foundation and deterministic replay acceptance are complete.","metadata":{"stages":"0,5,7","primary_stage":5},"labels":["epic","physics","primary-stage-5","stage-0","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e03","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e02","title":"EPIC-02 Death \u0026 Corpse","description":"Смерть как игровая механика, состояния тела, CorpseManager и respawn. Разделы 4–8.","acceptance_criteria":"Все типы смерти создают предсказуемое и пригодное для головоломки состояние тела.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:03Z","metadata":{"stages":"0,6,7","primary_stage":6},"labels":["corpse","death","epic","primary-stage-6","stage-0","stage-6","stage-7"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-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}
|
||||
@@ -49,28 +49,28 @@
|
||||
{"_type":"issue","id":"goon-game-ui-vslice","title":"Базовый HUD и multitouch controls","description":"В APK есть направления, jump, restart, pause, deaths и timer.","acceptance_criteria":"Управление работает одновременно двумя касаниями.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:21Z","close_reason":"Базовый HUD и multitouch controls реализованы.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-ui-vslice","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:22Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-camera-polish","title":"Доработать production camera controller","description":"Добавить vertical damping, room transitions, focus target и управляемый shake.","acceptance_criteria":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","notes":"Stage 5 RC: bounded horizontal/vertical damping, mechanism focus, impact shake, reduced-motion persistence; camera excluded from physics signatures; 35/35 Android gate GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:50Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:45Z","closed_at":"2026-08-18T23:01:45Z","close_reason":"Production camera behavior and reduced-motion acceptance are implemented and regression-tested.","metadata":{"stages":5,"primary_stage":5},"labels":["camera","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-camera-polish","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:16Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-camera-basic","title":"Базовая камера с look-ahead и bounds","description":"В текущем runtime реализовано сглаженное горизонтальное слежение.","acceptance_criteria":"Камера работает во всех 10 уровнях.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:18Z","close_reason":"Базовое сглаженное слежение реализовано.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-camera-basic","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:15Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-puzzle-events","title":"Выделить event-driven PuzzleSystem","description":"Заменить прямую проверку сигналов универсальными каналами событий и явными состояниями DoorController.","acceptance_criteria":"Кнопки, двери и платформы связываются данными; integration-тесты проходят.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","primary-stage-6","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-puzzle-events","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:13Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-puzzle-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":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","notes":"Stage 5 camera acceptance complete: bounded follow/focus/shake, level bounds and reduced motion; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:03Z","closed_at":"2026-08-18T23:03:03Z","close_reason":"Camera foundation and production polish acceptance are complete.","metadata":{"stages":"0,5","primary_stage":5},"labels":["camera","epic","primary-stage-5","stage-0","stage-5"],"dependencies":[{"issue_id":"goon-game-e06","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e05","title":"EPIC-05 Puzzle Objects","description":"Кнопки, двери, платформы, весовые объекты и событийные сигналы. Разделы 11–14, 37.","acceptance_criteria":"Механизмы не связаны жёсткими ссылками и корректно работают с живым персонажем и телами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:53Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:15Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","primary-stage-6","puzzle","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e05","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e04","title":"EPIC-04 Hazards","description":"Каталог опасностей и универсальный контракт источника смерти. Разделы 5, 10, 38.","acceptance_criteria":"Опасности data-driven, задокументированы и проходят Android/QA проверки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:11Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","hazards","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e04","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:45Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e01","title":"EPIC-01 Character","description":"Персонаж, state machine, движение и platforming. Разделы 3, 17, 28, 36.","acceptance_criteria":"Персонаж соответствует утверждённому набору состояний и стабильно управляется на Android.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:56Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["character","epic","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e01","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-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 без раздражающих повторов.","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}
|
||||
|
||||
@@ -11,13 +11,16 @@ Project documents:
|
||||
- [`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;
|
||||
@@ -28,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
|
||||
|
||||
@@ -72,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
|
||||
@@ -101,9 +112,51 @@ volunteer rig and three data-driven skins, adds procedural audio, camera focus a
|
||||
shake with reduced motion, save version 2 and active-session restoration. Its exact
|
||||
Android 16 candidate passed all 35 instrumentation methods in one unfiltered run
|
||||
(`493.521 s`) plus 72 JVM tests. Deterministic tapes replay all ten levels three
|
||||
times with identical state signatures. Engineering is complete; formal milestone
|
||||
acceptance still requires the independent 20-session playtest and is not claimed
|
||||
from bots.
|
||||
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:
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class StageFivePerformanceTest {
|
||||
@Test
|
||||
public void levelTenRendererPublishesAndMeetsTheReleaseBudget() throws Exception {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> {
|
||||
view[0] = current.gameViewForAutomation();
|
||||
view[0].startLevelForAutomation(10);
|
||||
});
|
||||
|
||||
// Activity launch includes landscape rotation and shader warm-up. Cold-start has a
|
||||
// separate release gate; this contract measures steady-state gameplay rendering.
|
||||
Thread.sleep(5_000L);
|
||||
activity.onActivity(current -> view[0].resetPerformanceMetricsForAutomation());
|
||||
Thread.sleep(10_000L);
|
||||
PerformanceMonitor.Snapshot[] measured = new PerformanceMonitor.Snapshot[1];
|
||||
activity.onActivity(current -> measured[0] =
|
||||
view[0].performanceSnapshotForAutomation());
|
||||
PerformanceMonitor.Snapshot metrics = measured[0];
|
||||
assertTrue("At least 180 uninterrupted frames are required; samples="
|
||||
+ metrics.sampleCount(), metrics.sampleCount() >= 180);
|
||||
PerformanceBudget budget = PerformanceBudget.release();
|
||||
assertTrue(metrics.failureSummary(budget), metrics.within(budget));
|
||||
assertTrue("Target FPS must be explicit", budget.targetFramesPerSecond() >= 60);
|
||||
assertTrue("Runtime body cap must fit the published budget",
|
||||
PhysicsConfig.release().maximumBodies() <= budget.maximumBodies());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class WeightedObjectIntegrationTest {
|
||||
@Test
|
||||
public void productionInputPushesSharedMassOntoPlateAndSaveCapturesEveryObject() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(17);
|
||||
|
||||
GameView.AutomationSnapshot initial = view.automationSnapshot();
|
||||
assertEquals(5, initial.physicalObjectCount());
|
||||
assertEquals("COUNTERWEIGHT", initial.firstPhysicalObjectType());
|
||||
assertEquals(3, initial.firstPhysicalObjectMass());
|
||||
assertFalse(initial.allDoorsOpen());
|
||||
|
||||
view.setAutomationInput(false, true, false);
|
||||
view.advanceDeterministicAutomation(3_000L);
|
||||
GameView.AutomationSnapshot pushed = view.automationSnapshot();
|
||||
assertTrue(pushed.firstPhysicalObjectX() >= 920f);
|
||||
assertTrue(pushed.allDoorsOpen());
|
||||
|
||||
GameSessionSnapshot saved = view.sessionSnapshotForAutomation();
|
||||
assertEquals(5, saved.objects().size());
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
|
||||
saved.objects().get(0).type());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class WeightedObjectPlaythroughTest {
|
||||
@Test
|
||||
public void allFiveObjectTypesOpenTheMassNineGateThreeTimes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
Driver driver = Driver.from(activity);
|
||||
for (int run = 1; run <= 3; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
driver, WeightedObjectScenarios.levelSeventeen(), 50_000L);
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("weighted route is deathless", 0, result.deaths());
|
||||
assertEquals("weighted route does not create bodies", 0, result.corpseCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Driver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
private Driver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
static Driver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] target = new GameView[1];
|
||||
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
|
||||
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(() -> view.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean left, boolean right, boolean jump) {
|
||||
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> snapshot[0] = view.automationSnapshot());
|
||||
return snapshot[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
Thread.sleep(Math.max(32L, millis));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Weighted-object bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Production-input route for the weighted-object teaching puzzle. */
|
||||
final class WeightedObjectScenarios {
|
||||
private WeightedObjectScenarios() {}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelSeventeen() {
|
||||
return LevelPlaytestBot.Scenario.builder(17, "Level 17 - shared mass gate")
|
||||
.step("Observe all authored weight types", 1_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.physicalObjectCount() == 5
|
||||
&& snapshot.firstPhysicalObjectMass() == 3
|
||||
&& "COUNTERWEIGHT".equals(snapshot.firstPhysicalObjectType())
|
||||
&& !snapshot.allDoorsOpen())
|
||||
.step("Approach the counterweight", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Push the missing mass onto the plate", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.firstPhysicalObjectX() >= 920f
|
||||
&& snapshot.allDoorsOpen()))
|
||||
.step("Release input while object mass holds the gate", 1_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.allDoorsOpen()
|
||||
&& snapshot.firstPhysicalObjectX() >= 920f)
|
||||
.step("Take off over the counterweight", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 700f))
|
||||
.step("Land beyond the counterweight", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 1_080f))
|
||||
.step("Jump over the weighted row", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_650f))
|
||||
.step("Pass the mass-nine door and reach the relic", 5_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ public enum AudioCue {
|
||||
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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public final class DeterministicInputTape {
|
||||
private final ArrayList<Command> commands = new ArrayList<>();
|
||||
|
||||
private Builder(int levelNumber) {
|
||||
if (levelNumber < 1 || levelNumber > 10) {
|
||||
if (levelNumber < 1 || levelNumber > LevelCatalog.all().size()) {
|
||||
throw new IllegalArgumentException("Unknown level: " + levelNumber);
|
||||
}
|
||||
this.levelNumber = levelNumber;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public final class GameSessionSnapshot {
|
||||
|
||||
public static final class BodyData {
|
||||
private final float x, y, width, height, velocityX, velocityY;
|
||||
private final boolean frozen, conductive, pinned, grounded;
|
||||
private final boolean frozen, conductive, pinned, grounded, crushed, burned;
|
||||
private final int supportIndex;
|
||||
private final String deathType;
|
||||
|
||||
@@ -51,6 +51,15 @@ public final class GameSessionSnapshot {
|
||||
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;
|
||||
@@ -61,6 +70,8 @@ public final class GameSessionSnapshot {
|
||||
this.conductive = conductive;
|
||||
this.pinned = pinned;
|
||||
this.grounded = grounded;
|
||||
this.crushed = crushed;
|
||||
this.burned = burned;
|
||||
this.supportIndex = supportIndex;
|
||||
this.deathType = deathType == null ? "SPIKE" : deathType;
|
||||
}
|
||||
@@ -75,19 +86,58 @@ public final class GameSessionSnapshot {
|
||||
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<>();
|
||||
@@ -102,8 +152,10 @@ public final class GameSessionSnapshot {
|
||||
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);
|
||||
@@ -129,8 +181,10 @@ public final class GameSessionSnapshot {
|
||||
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;
|
||||
@@ -142,8 +196,10 @@ public final class GameSessionSnapshot {
|
||||
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(
|
||||
@@ -159,8 +215,10 @@ public final class GameSessionSnapshot {
|
||||
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; }
|
||||
|
||||
@@ -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() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически."
|
||||
@@ -116,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"; }
|
||||
@@ -156,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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ package com.projectsacrifice.game;
|
||||
public final class PhysicsConfig {
|
||||
private static final PhysicsConfig RELEASE = new PhysicsConfig(
|
||||
1, 1f / 60f, 1_800f, 370f, 880f,
|
||||
0.13f, 0.14f, 0.78f, 16);
|
||||
0.13f, 0.14f, 0.78f, 0.10f, 0.12f, 0.24f, 0.32f, 16);
|
||||
|
||||
private final int version;
|
||||
private final float fixedStepSeconds;
|
||||
@@ -14,12 +14,17 @@ public final class PhysicsConfig {
|
||||
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,
|
||||
int maximumBodies) {
|
||||
float turnSeconds, float landSeconds, float interactSeconds,
|
||||
float stunnedSeconds, int maximumBodies) {
|
||||
this.version = version;
|
||||
this.fixedStepSeconds = fixedStepSeconds;
|
||||
this.gravity = gravity;
|
||||
@@ -28,6 +33,10 @@ public final class PhysicsConfig {
|
||||
this.coyoteSeconds = coyoteSeconds;
|
||||
this.jumpBufferSeconds = jumpBufferSeconds;
|
||||
this.respawnSeconds = respawnSeconds;
|
||||
this.turnSeconds = turnSeconds;
|
||||
this.landSeconds = landSeconds;
|
||||
this.interactSeconds = interactSeconds;
|
||||
this.stunnedSeconds = stunnedSeconds;
|
||||
this.maximumBodies = maximumBodies;
|
||||
}
|
||||
|
||||
@@ -40,5 +49,9 @@ public final class PhysicsConfig {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,18 @@ final class ProceduralAudioEngine {
|
||||
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
|
||||
@@ -126,6 +138,10 @@ final class ProceduralAudioEngine {
|
||||
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;
|
||||
|
||||
@@ -5,7 +5,7 @@ import android.content.SharedPreferences;
|
||||
|
||||
public final class ProgressStore {
|
||||
private static final String FILE = "project_sacrifice_progress_v1";
|
||||
public static final int CURRENT_SAVE_VERSION = 2;
|
||||
public static final int CURRENT_SAVE_VERSION = 3;
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
public ProgressStore(Context context) {
|
||||
@@ -18,26 +18,60 @@ 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);
|
||||
}
|
||||
@@ -45,11 +79,56 @@ public final class ProgressStore {
|
||||
private void migrateIfSupported() {
|
||||
int version = preferences.getInt("save_version", 1);
|
||||
if (version >= 1 && version < CURRENT_SAVE_VERSION) {
|
||||
preferences.edit().putInt("save_version", CURRENT_SAVE_VERSION).apply();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,10 @@ import java.util.Map;
|
||||
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 = 2;
|
||||
public static final int CURRENT_VERSION = 5;
|
||||
|
||||
public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT }
|
||||
|
||||
@@ -41,6 +42,18 @@ public final class VersionedSessionCodec {
|
||||
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();
|
||||
@@ -64,7 +77,9 @@ public final class VersionedSessionCodec {
|
||||
if (version < 1) return result(Status.CORRUPT, null);
|
||||
|
||||
int levelNumber = input.readInt();
|
||||
if (levelNumber < 1 || levelNumber > 10) return result(Status.CORRUPT, null);
|
||||
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()))
|
||||
@@ -73,7 +88,12 @@ public final class VersionedSessionCodec {
|
||||
.player(readPlayer(input));
|
||||
|
||||
int bodyCount = checkedCount(input.readInt(), MAX_BODIES);
|
||||
for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input));
|
||||
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);
|
||||
@@ -106,7 +126,16 @@ public final class VersionedSessionCodec {
|
||||
output.writeFloat(snapshot.respawnSeconds());
|
||||
writePlayer(output, snapshot.player());
|
||||
output.writeInt(snapshot.bodies().size());
|
||||
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) writeBody(output, body);
|
||||
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());
|
||||
@@ -143,7 +172,8 @@ public final class VersionedSessionCodec {
|
||||
input.readInt(), input.readInt());
|
||||
}
|
||||
|
||||
private static void writeBody(DataOutputStream output, GameSessionSnapshot.BodyData body)
|
||||
private static void writeBody(DataOutputStream output, GameSessionSnapshot.BodyData body,
|
||||
int version)
|
||||
throws IOException {
|
||||
output.writeFloat(body.x());
|
||||
output.writeFloat(body.y());
|
||||
@@ -155,11 +185,15 @@ public final class VersionedSessionCodec {
|
||||
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)
|
||||
private static GameSessionSnapshot.BodyData readBody(DataInputStream input, int version)
|
||||
throws IOException {
|
||||
float x = finite(input.readFloat());
|
||||
float y = finite(input.readFloat());
|
||||
@@ -171,12 +205,47 @@ public final class VersionedSessionCodec {
|
||||
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,
|
||||
support, deathType);
|
||||
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)
|
||||
|
||||
@@ -49,8 +49,13 @@ public final class VolunteerRig {
|
||||
public enum Motion {
|
||||
IDLE,
|
||||
RUN,
|
||||
TURN,
|
||||
JUMP,
|
||||
FALL,
|
||||
LAND,
|
||||
INTERACT,
|
||||
STUNNED,
|
||||
DEATH,
|
||||
DEAD,
|
||||
PINNED,
|
||||
FROZEN,
|
||||
@@ -117,6 +122,12 @@ public final class VolunteerRig {
|
||||
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;
|
||||
@@ -156,6 +167,14 @@ public final class VolunteerRig {
|
||||
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;
|
||||
@@ -174,6 +193,28 @@ public final class VolunteerRig {
|
||||
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:
|
||||
@@ -196,7 +237,8 @@ public final class VolunteerRig {
|
||||
if (facing < 0) {
|
||||
for (int i = 0; i < xs.length; i++) xs[i] = 1f - xs[i];
|
||||
}
|
||||
boolean visorLit = safeMotion != Motion.DEAD
|
||||
boolean visorLit = safeMotion != Motion.DEATH
|
||||
&& safeMotion != Motion.DEAD
|
||||
&& safeMotion != Motion.PINNED
|
||||
&& safeMotion != Motion.FROZEN
|
||||
&& safeMotion != Motion.CONDUCTIVE;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,15 @@ public final class DeterministicInputTapeTest {
|
||||
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;
|
||||
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ public final class PhysicsConfigTest {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ public final class VersionedSessionCodecTest {
|
||||
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);
|
||||
@@ -43,6 +50,41 @@ public final class VersionedSessionCodecTest {
|
||||
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
|
||||
@@ -58,20 +100,45 @@ public final class VersionedSessionCodecTest {
|
||||
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, -1, "SPIKE"))
|
||||
false, false, true, true, true, false,
|
||||
-1, "CRUSH"))
|
||||
.addBody(new GameSessionSnapshot.BodyData(
|
||||
720f, 826f, 72f, 54f, 0f, 0f,
|
||||
false, true, true, true, -1, "ELECTRIC"))
|
||||
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)
|
||||
|
||||
@@ -84,4 +84,32 @@ public final class VolunteerRigTest {
|
||||
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`.
|
||||
|
||||
## Ежедневные команды
|
||||
|
||||
|
||||
+67
-4
@@ -154,10 +154,19 @@ baseline-задачи также имели явного владельца по
|
||||
- progress восстанавливается после Activity/process recreation и обновления приложения;
|
||||
- `goon-game-milestone-vslice` принят вместе с performance и human-playtest gates.
|
||||
|
||||
Статус на 19 августа 2026: инженерный candidate `0.8.0-stage5-vslice` готов.
|
||||
Art/audio/camera/save/determinism/performance реализованы; 72 JVM-теста и единый
|
||||
Android 16 run `35/35` (`493.521 s`) GREEN. Milestone намеренно остаётся открытым
|
||||
до 20 независимых no-explanation сессий по протоколу `STAGE5_HUMAN_PLAYTEST.md`.
|
||||
Статус на 19 августа 2026: Stage 5 принят. Инженерный candidate
|
||||
`0.8.0-stage5-vslice` прошёл 72 JVM-теста и единый Android 16 run `35/35`
|
||||
(`493.521 s`). Владелец продукта подтвердил завершение внешнего тестирования и
|
||||
переход дальше; границы доступного human evidence зафиксированы в
|
||||
`STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 6 разблокирован.
|
||||
|
||||
Текущий статус Stage 6: foundation, production hazards, burned/crushed corpse
|
||||
states, weighted objects и challenge/progression приняты. Каталог содержит 18
|
||||
уровней. Stable `levelId`/`worldId`, casualty/time/cache challenges, progress-v3
|
||||
migration, session-v5 collectible state и persistent three-star result прошли
|
||||
`116` app JVM, `2` content-tool JVM и нефильтрованную Android 16 матрицу `52/52`
|
||||
за `551.773 s`. Следующий dependency-order slice — массовое производство четырёх
|
||||
миров.
|
||||
|
||||
### Этап 6. Production content — P1, несколько итераций
|
||||
|
||||
@@ -177,6 +186,60 @@ Android 16 run `35/35` (`493.521 s`) GREEN. Milestone намеренно ост
|
||||
- content не содержит blocker-softlocks и обязательных невосстановимых объектов;
|
||||
- принят `goon-game-milestone-production`.
|
||||
|
||||
Статус на 19 августа 2026: Stage 6 выполняется в изолированном production stream.
|
||||
Завершены три foundation slice. `goon-game-character-states` добавил
|
||||
явные Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions.
|
||||
`goon-game-corpse-manager` перенёс регистрацию, transforms, FIFO budget, reset,
|
||||
save/restore и diagnostics тел в отдельного владельца без изменения физики и
|
||||
геометрии. `goon-game-puzzle-events` выделил pressure/toggle/timed/relay channels,
|
||||
immutable events и явные DoorController states; двери и платформы читают общую
|
||||
data-driven модель. Завершён первый mechanics slice: production-каталог hazards
|
||||
(`crusher`, `fire`, `projectile`, `explosion`), обучающие уровни 11–14,
|
||||
динамическая пагинация каталога и 12/12 bot-прохождений. Последний gate:
|
||||
99 JVM-тестов и Android 16 matrix `43/43` (`2004.172 s`). Следующая зависимая
|
||||
итерация завершена: `CRUSHED` и `BURNED`, save migration и обучающие уровни
|
||||
15–16 приняты clean gate `102 JVM` и unfiltered Android matrix `46/46`
|
||||
(`1,842.248 s`). Weighted-object slice также принят: общий `IWeightedObject`,
|
||||
Box/Rock/Ball/IceBlock/Counterweight, save v4 и обучающий уровень 17 прошли clean
|
||||
gate `108` app JVM + `2` content-tool JVM и unfiltered Android matrix `48/48`
|
||||
(`2,223.695 s`). Evidence зафиксирован в `STAGE6_WEIGHTED_OBJECTS.md`.
|
||||
Challenge/progression slice также принят: stable level/world IDs, три независимых
|
||||
challenge-критерия, optional collectible, накопительные лучшие звёзды по `levelId`,
|
||||
progress v1/v2 → v3 и active-session v4 → v5 migration, result UI и обучающий
|
||||
уровень 18 прошли clean gate `116` app JVM + `2` content-tool JVM и unfiltered
|
||||
Android matrix `52/52` (`551.773 s`). Evidence — `STAGE6_CHALLENGES.md`.
|
||||
Следующий content gate зафиксирован исполняемой матрицей: релиз содержит 69
|
||||
уровней, из них production-диапазон 18–69 разделён на четыре мира по 13 уровней.
|
||||
Каждый мир содержит 11 MAIN, один CHALLENGE и один FINALE; Gradle `preBuild`
|
||||
проверяет матрицу и соответствие авторизованного JSON-префикса. Контракт —
|
||||
`STAGE6_CONTENT_MATRIX.md`; следующий authoring wave — Foundations 19–30.
|
||||
Первый bounded wave Levels 19–22 принят: movement/jump, два тела над раздельными
|
||||
spikes, retained pressure Door и toggle ferry имеют solution graphs, collectibles,
|
||||
restart proof и 12/12 production-input playthroughs. Cold unfiltered candidate
|
||||
прошёл Android `57/57` за `1807.264 s`, app JVM `117/117`, content-tool JVM `7/7`.
|
||||
Evidence — `STAGE6_FOUNDATIONS_WAVE1.md`.
|
||||
Второй bounded wave Levels 23–26 также принят: remote saw delivery, frozen step,
|
||||
conductive relay и timed ferry с дальним recovery relay имеют solution graphs,
|
||||
collectibles и 15/15 последовательных production-input playthroughs. Cold
|
||||
unfiltered candidate прошёл Android `62/62` за `1063.114 s`, app JVM `118/118`,
|
||||
content-tool JVM `7/7`; APK содержит все 26 level JSON. Evidence —
|
||||
`STAGE6_FOUNDATIONS_WAVE2.md`.
|
||||
Финальный Foundations wave Levels 27–30 принят: AND-door на двух retained bodies,
|
||||
трёхсостоянийная цепочка с clean restart, exact-two-death challenge и finale
|
||||
bridge → pressure → toggle/timed ferry → Door имеют solution graphs, restart proof
|
||||
и 12/12 production-input playthroughs. Полный cold candidate прошёл Android
|
||||
`66/66` за `6749.094 s`, app JVM `119/119`, content-tool JVM `7/7`; APK содержит
|
||||
30 level JSON. Evidence — `STAGE6_FOUNDATIONS_WAVE3.md`. Мир Foundations 18–30
|
||||
закрыт; следующий bounded slice — Physics 31–34.
|
||||
Первый Physics wave Levels 31–34 принят: mass-three object удерживает Door,
|
||||
трёхобъектная лестница использует новый детерминированный object/object support,
|
||||
saw-delivery соединён с вертикальным lift, crushed body сохраняется на pressure
|
||||
во время полного crusher cycle. Четыре метода доказали 12/12 production-input
|
||||
routes и restart сдвинутого противовеса. Cold unfiltered candidate прошёл Android
|
||||
`70/70` за `611.991 s`, app JVM `122/122`, content-tool JVM `7/7`; APK содержит
|
||||
34 level JSON. Evidence — `STAGE6_PHYSICS_WAVE1.md`. Следующий bounded slice —
|
||||
Physics 35–38.
|
||||
|
||||
### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
|
||||
|
||||
Задачи:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Графы решений уровней 1–10
|
||||
# Графы решений уровней 1–18
|
||||
|
||||
Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения.
|
||||
|
||||
На 18 августа 2026 года intended route всех уровней 1–10 автоматически выполнен по три раза подряд без retry masking. `EarlyLevelPlaythroughTest`, `LevelFivePlaythroughTest`, `LateLevelPlaythroughTest` и `LevelEightPlaythroughTest` управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`.
|
||||
На 19 августа 2026 года intended route всех уровней 1–18 автоматически выполнен по три раза подряд без retry masking. Device-тесты управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`.
|
||||
|
||||
## Семантика, общая для графов
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- `TIMED` запускает таймер только на фронте контакта; удержание на переключателе не перезапускает истёкший таймер.
|
||||
- `Door.requiredSignals` использует AND-семантику: дверь открыта, только когда активны все указанные сигналы.
|
||||
- Управляемая движущаяся платформа накапливает `motionTime` только при активном `controlId`; при потере сигнала она замирает в текущей фазе `GameMath.pingPong`.
|
||||
- `SPIKES` оставляет закреплённое тело над шипами; `SAW` передаёт телу `impulseX`/`impulseY`; `FREEZER` создаёт неподвижный блок `104×104`; `ELECTRIC` оставляет закреплённое проводящее тело внутри опасности; `PIT` тела не оставляет.
|
||||
- `SPIKES` оставляет закреплённое тело над шипами; `SAW` передаёт телу `impulseX`/`impulseY`; `FREEZER` создаёт неподвижный блок `104×104`; `ELECTRIC` оставляет закреплённое проводящее тело внутри опасности; `CRUSHER` создаёт низкое `CRUSHED`-тело; `FIRE` оставляет твёрдое `BURNED`-тело без pressure mass; `PIT` тела не оставляет.
|
||||
- Тела являются твёрдыми опорами для игрока. Смерть сохраняет тела и состояния уровня, respawn возвращает нового игрока в `spawnX`/`spawnY`; restart очищает состояние.
|
||||
- Пересечение живого игрока с `goal` переводит сцену в `COMPLETE`.
|
||||
|
||||
@@ -285,9 +285,479 @@ Goal расположен на верхнем уступе за PIT и закр
|
||||
|
||||
**Softlock/неопределённость:** исправлены два независимых blocker-состояния: saw-тело больше не соскальзывает с плиты после доставки, а перекрытие floor/lift увеличено до 140 px при сохранении 200 px `PIT` и обязательного вертикального подъёма. Период lift увеличен до `10.0 s`, чтобы нижняя фаза была читаемой при задержанном вводе. Бот теперь явно приземляется на frozen corpse и прыгает с него; доставка saw-тела, три сохранённых тела, посадка, подъём и lift→ledge прыжок подтверждены тремя запусками и стресс-сценарием. Возможная альтернативная двухсмертная трасса не считается blocker, пока intended three-body route остаётся стабильным.
|
||||
|
||||
## Уровень 11 — `The Brass Breather`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 изолированно и 3/3 в общей hazard-матрице.
|
||||
**Ожидаемые и доказанные смерти:** `1`.
|
||||
|
||||
```text
|
||||
CRUSHER перекрывает единственный проход
|
||||
→ первый доброволец входит в активный пресс и оставляет тело
|
||||
→ следующий доброволец подходит к безопасной границе
|
||||
→ наблюдает закрытие и следующий переход пресса в REST
|
||||
→ прыгает через сохранённое тело и проходит пресс в полном окне отдыха
|
||||
→ достигает goal с одной смертью
|
||||
```
|
||||
|
||||
**Контракт:** `cycleSeconds=4`, `activeSeconds=2.5`; collision активен только в
|
||||
CRUSH-фазе. Bot snapshot наблюдает `firstHazardActive`, не использует sleeps,
|
||||
restart или прямую мутацию состояния.
|
||||
|
||||
## Уровень 12 — `Blue Funeral`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 изолированно и 3/3 в общей hazard-матрице.
|
||||
**Ожидаемые и доказанные смерти:** `1`.
|
||||
|
||||
```text
|
||||
FIRE перекрывает floor
|
||||
→ первый доброволец демонстрирует смертельность синего огня
|
||||
→ после respawn игрок подходит к рассчитанной точке x≈535
|
||||
→ отдельным takeoff подтверждает airborne state
|
||||
→ пересекает весь flame box до приземления
|
||||
→ достигает goal, тело остаётся в уровне
|
||||
```
|
||||
|
||||
**Контракт:** постоянный `FIRE=(700,790,120,90)` с отдельными visual/audio IDs.
|
||||
Маршрут запрещает вторую смерть на approach, takeoff и clear checkpoints.
|
||||
|
||||
## Уровень 13 — `Crossfire Clock`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 изолированно и 3/3 в общей hazard-матрице.
|
||||
**Ожидаемые и доказанные смерти:** `1`.
|
||||
|
||||
```text
|
||||
PROJECTILE каждые 3 s проходит справа налево
|
||||
→ первый доброволец погибает и показывает линию снаряда
|
||||
→ после respawn игрок останавливается у x≈600
|
||||
→ ждёт наблюдаемое окно projectile x=850..930 и прыгает через первый проход
|
||||
→ останавливается за линией и ждёт следующий цикл x=1350..1450
|
||||
→ вторым прыжком проходит возвращённый снаряд
|
||||
→ добегает за spawn снаряда и достигает goal без новых смертей
|
||||
```
|
||||
|
||||
**Контракт:** runtime вычисляет текущий box из `speedX=-400` и сбрасывает его
|
||||
на исходную позицию по `cycleSeconds=3`. Оба прыжка привязаны к snapshot
|
||||
координате, а не к wall-clock.
|
||||
|
||||
## Уровень 14 — `Steam Charge`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 изолированно и 3/3 в общей hazard-матрице.
|
||||
**Ожидаемые и доказанные смерти:** `1`; дверь обязана открыться.
|
||||
|
||||
```text
|
||||
Door требует signal "blast-gate" от удалённой PRESSURE
|
||||
→ первый доброволец входит в активный EXPLOSION
|
||||
→ impulse (550,-500) доставляет тело на PRESSURE x=980..1160
|
||||
→ тело закрепляется на плите и открывает Door
|
||||
→ следующий доброволец наблюдает новый BLAST и следующую REST-фазу
|
||||
→ проходит inactive explosion
|
||||
→ перепрыгивает сохранённое тело на плите
|
||||
→ проходит открытую Door и достигает goal
|
||||
```
|
||||
|
||||
**Контракт:** `EXPLOSION` использует data-driven цикл `4/2 s`; inactive-фаза
|
||||
отличается визуально (`REST`) и не убивает. Device RED с `impulseX=1400`
|
||||
доказал перелёт тела за плиту; минимальное authored значение `550` стабильно
|
||||
помещает тело в `firstCorpseX=980`, после чего `allDoorsOpen=true`.
|
||||
|
||||
## Уровень 15 — `Pressed Into Service`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `1`; сохранённое тело обязано иметь состояние
|
||||
`CRUSHED`, размер `140×24` и оставаться твёрдой опорой.
|
||||
|
||||
```text
|
||||
Верхний ledge начинается на высоте y=720 и недоступен прыжком с floor
|
||||
→ первый доброволец входит в активный CRUSHER
|
||||
→ пресс превращает тело в низкую закреплённую плиту 140×24
|
||||
→ следующий доброволец наблюдает новый активный цикл и ждёт REST
|
||||
→ проходит безопасный пресс и приземляется на crushed body
|
||||
→ отдельным прыжком использует 24 px ступень для достижения ledge
|
||||
→ проходит по верхней платформе к goal
|
||||
→ COMPLETE ровно с одной смертью и одним crushed body
|
||||
```
|
||||
|
||||
**Контракт:** `CRUSHED` композиционно сохраняется в `CorpseManager`, уменьшает
|
||||
коллайдер и не удаляет физическую опору. Save version 3 сохраняет флаг; v1/v2
|
||||
мигрируют его в `false`. Snapshot отдельно публикует state, размеры и pressure
|
||||
capability, а Canvas добавляет текстовую метку `CRUSHED` помимо цвета.
|
||||
|
||||
## Уровень 16 — `Ashen Counterweight`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `1`; сохранённое тело обязано иметь состояние
|
||||
`BURNED`, оставаться твёрдым и не активировать `PRESSURE`.
|
||||
|
||||
```text
|
||||
Moving bridge над PIT управляется signal "burn-weight"
|
||||
→ первый доброволец пересекает PRESSURE и погибает в FIRE над плитой
|
||||
→ burned body остаётся на плите как твёрдый объект
|
||||
→ burned body не даёт pressure mass: signal остаётся inactive
|
||||
→ мост остаётся в нижней фазе у y≈840 вместо подъёма
|
||||
→ следующий доброволец перепрыгивает FIRE и burned body
|
||||
→ садится на неподвижный мост и пересекает PIT
|
||||
→ достигает goal на правом floor
|
||||
→ COMPLETE ровно с одной смертью и одним burned body
|
||||
```
|
||||
|
||||
**Контракт:** `BURNED` сохраняет коллайдер, но `activatesPressure=false`; это
|
||||
проверяется одновременно состоянием тела, неактивным `firstMovingPlatformPowered`
|
||||
и нижней координатой моста. Save version 3 сохраняет флаг; Canvas добавляет
|
||||
текстовую метку `BURNED`, поэтому состояние не кодируется только цветом.
|
||||
|
||||
## Уровень 17 — `The Whole Weight`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `0`; дверь обязана удерживаться только
|
||||
физическими объектами после отпускания player input.
|
||||
|
||||
```text
|
||||
Door требует signal "weight-gate" с requiredMass=9
|
||||
→ на PRESSURE уже стоят Box(1), Rock(2), Ball(1) и IceBlock(2): сумма 6
|
||||
→ Counterweight(3) находится слева от плиты, поэтому Door закрыта
|
||||
→ игрок подходит и толкает Counterweight вправо
|
||||
→ при x≥920 Counterweight пересекает plate x=1000..1650
|
||||
→ общий IWeightedObject resolver получает массу 6+3=9 и открывает Door
|
||||
→ игрок отпускает input; объект продолжает удерживать сигнал без player mass
|
||||
→ игрок двумя отдельными прыжками пересекает Counterweight и ряд объектов
|
||||
→ проходит открытую Door и достигает goal
|
||||
→ COMPLETE без смерти и без создания тел
|
||||
```
|
||||
|
||||
**Контракт:** `Player`, `CorpseManager.Body` и `PhysicalObjectState` реализуют
|
||||
один `IWeightedObject`; plate агрегирует только геометрически пересекающуюся
|
||||
активную массу, не проверяя тип. Все пять authored типов имеют type/box/mass в
|
||||
JSON, детерминированный runtime, non-color type+weight label, SVG/TSV preview и
|
||||
save-v4 состояние. V1–v3 active sessions мигрируют с пустым runtime-object list.
|
||||
|
||||
**Bot checkpoints:** каталог публикует пять objects и Counterweight mass 3;
|
||||
до push `allDoorsOpen=false`; после push `firstPhysicalObjectX≥920` и Door open;
|
||||
после release Door остаётся open; затем два отдельных jump edge и `completed=true`.
|
||||
|
||||
## Уровень 18 — `Threefold Proof`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `0`; обязательный challenge-route получает
|
||||
casualty, time и collectible stars.
|
||||
|
||||
```text
|
||||
LevelID="foundations.threefold-proof", WorldID="foundations"
|
||||
→ игрок проходит безопасный teaching floor без смертей
|
||||
→ у x≈650 начинает прыжок к cache=(850,650,58,58)
|
||||
→ пересекает cache: collectibleFound=true, cache исчезает только для этой попытки
|
||||
→ продолжает вправо и достигает goal x=2000..2086
|
||||
→ COMPLETE с deaths=0 и time≤30 s
|
||||
→ ChallengeResult выдаёт casualty/time/collectible = true, stars=3
|
||||
→ ProgressStore сохраняет лучшие критерии по стабильному LevelID
|
||||
→ три последовательных restart-free прохождения воспроизводят 3/3 stars
|
||||
```
|
||||
|
||||
**Контракт:** collectible является опциональным world-space объектом и сохраняется
|
||||
в active session v5. Restart сбрасывает состояние текущей попытки; завершённый
|
||||
collectible challenge накапливается в progress v3 независимо от лучших casualty и
|
||||
time runs. Legacy levels без collectible получают третью звезду за core completion.
|
||||
|
||||
**Bot checkpoints:** `deaths=0`; после прыжка `collectibleFound=true` и
|
||||
`collectibleVisible=false`; затем `completed=true`; `CompletionResult.stars=3`,
|
||||
`bestStars=3`; новый `ProgressStore` после трёх запусков возвращает 3 звезды для
|
||||
`foundations.threefold-proof`.
|
||||
|
||||
## Уровень 19 — `First Return`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `0`.
|
||||
|
||||
```text
|
||||
LevelID="foundations.first-return", WorldID="foundations"
|
||||
→ игрок подходит к нижнему уступу x=650..890 и перепрыгивает его
|
||||
→ отпускает jump, подтверждает новое касание пола и создаёт второй jump edge
|
||||
→ вторым прыжком проходит высокий уступ x=1080..1380
|
||||
→ на безопасной дальней прямой забирает collectible x=1500
|
||||
→ достигает goal x=2090..2176 без смертей
|
||||
```
|
||||
|
||||
**Контракт:** уровень повторно калибрует движение и раздельные jump edges перед
|
||||
production-комбинациями. На нём нет hazard, switch или Door; failure означает
|
||||
ошибку управления, а не скрытое состояние головоломки.
|
||||
|
||||
## Уровень 20 — `Covered Path`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `2`; оба тела сохраняются.
|
||||
|
||||
```text
|
||||
LevelID="foundations.covered-path", WorldID="foundations"
|
||||
→ первый доброволец идёт в ближние spikes x=700..800 и оставляет pinned body
|
||||
→ второй прыгает с x≈520 через первое тело и погибает на spikes x=930..1030
|
||||
→ третий возвращается и одним прыжком пересекает две раздельные опоры
|
||||
→ отдельным jump edge входит на ledge x=1240..1520 и забирает collectible
|
||||
→ достигает goal с deaths=2, corpses=2
|
||||
```
|
||||
|
||||
**Контракт:** две раздельные spike-зоны делают обе жертвы наблюдаемыми и не
|
||||
позволяют одним ранним телом случайно закрыть весь hazard. Bot fail-fast отклоняет
|
||||
третью смерть; три последовательных запуска доказывают очистку старых тел.
|
||||
|
||||
## Уровень 21 — `Held Open`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `1`; Door остаётся открыта телом.
|
||||
|
||||
```text
|
||||
LevelID="foundations.held-open", WorldID="foundations"
|
||||
→ первый доброволец достигает saw x=650..742
|
||||
→ тело оседает на pressure plate x=625..785 и активирует held-gate
|
||||
→ следующий доброволец подтверждает allDoorsOpen=true
|
||||
→ начинает прыжок из рассчитанного окна x≈470 и очищает saw + retained body
|
||||
→ прыгает через collectible x=1160 до Door x=1260
|
||||
→ проходит удерживаемую Door и достигает goal с deaths=1, corpses=1
|
||||
```
|
||||
|
||||
**Контракт:** plate и Door используют один signal ID `held-gate`. Коллекционный
|
||||
маршрут лежит внутри того же доказанного прыжка и не требует второй жертвы.
|
||||
|
||||
## Уровень 22 — `Clockwork Step`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `0`.
|
||||
|
||||
```text
|
||||
LevelID="foundations.clockwork-step", WorldID="foundations"
|
||||
→ игрок один раз касается toggle ferry x=760..832
|
||||
→ подходит к launch overlap и прыгает на platform x=940..1260
|
||||
→ snapshot подтверждает playerOnGround && playerOnMovingPlatform
|
||||
→ без input едет до platformX≥1280 и по пути забирает collectible x=1435
|
||||
→ сходит на дальний floor x=1600..2500, не касаясь PIT x=1100..1600
|
||||
→ достигает goal без смерти
|
||||
```
|
||||
|
||||
**Контракт:** toggle и moving platform разделяют signal `ferry`. Платформа имеет
|
||||
160 px launch overlap до движения и 20 px far overlap в конечной фазе; bot ждёт
|
||||
реальную поддержку, а не единичное пересечение в воздухе.
|
||||
|
||||
## Уровень 23 — `Remote Volunteer`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `1`; доставленное тело удерживает Door.
|
||||
|
||||
```text
|
||||
LevelID="foundations.remote-volunteer", WorldID="foundations"
|
||||
→ первый доброволец входит в saw x=600..696 и получает impulse (550,-700)
|
||||
→ тело попадает на raised pressure plate x=1030..1200 и открывает remote-gate
|
||||
→ следующий доброволец стартует прыжок до saw и остаётся жив
|
||||
→ перепрыгивает доставленное тело, забирает cache x=1180
|
||||
→ проходит Door x=1540..1622 и достигает goal с deaths=1, corpses=1
|
||||
```
|
||||
|
||||
**Контракт:** дистанционно доставленное тело является единственным источником
|
||||
pressure-сигнала. Маршрут не маскирует повторную смерть и ограничен максимум одной
|
||||
жертвой на каждом шаге.
|
||||
|
||||
## Уровень 24 — `Frozen Step`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `1`; одно замороженное тело становится ступенью.
|
||||
|
||||
```text
|
||||
LevelID="foundations.frozen-step", WorldID="foundations"
|
||||
→ первый доброволец замерзает в FREEZER x=720..820
|
||||
→ следующий прыгает и стабилизируется на frozen body на y≈680
|
||||
→ отдельным jump edge поднимается на ledge x=850..1410, y=590
|
||||
→ забирает cache x=1280 и перепрыгивает на ledge x=1500..2200
|
||||
→ достигает верхнего goal с deaths=1, corpses=1
|
||||
```
|
||||
|
||||
**Контракт:** без сохранённого frozen body высота первого уступа недостижима
|
||||
доказанным маршрутом; бот отдельно наблюдает устойчивую опору на теле.
|
||||
|
||||
## Уровень 25 — `Living Wire`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `1`; проводящее тело постоянно питает Door.
|
||||
|
||||
```text
|
||||
LevelID="foundations.living-wire", WorldID="foundations"
|
||||
→ первый доброволец погибает в ELECTRIC x=720..830
|
||||
→ conductive corpse активирует relayId="living-power" и открывает Door
|
||||
→ следующий стартует прыжок до live relay
|
||||
→ перелетает тело, забирает cache x=1100 на безопасном полу
|
||||
→ проходит Door x=1350..1434 и достигает goal с deaths=1, corpses=1
|
||||
```
|
||||
|
||||
**Контракт:** Door открывается только живым relay-сигналом сохранённого conductive
|
||||
body; каждый шаг fail-fast запрещает вторую смерть.
|
||||
|
||||
## Уровень 26 — `Second Hand`
|
||||
|
||||
**Статус:** доказаны два production-input маршрута, каждый 3/3.
|
||||
**Ожидаемые и доказанные смерти:** `0` для original timer и recovery.
|
||||
|
||||
```text
|
||||
LevelID="foundations.borrowed-time", WorldID="foundations"
|
||||
→ TIMED switch x=760 запускает second-hand на 9 s и outbound ferry
|
||||
→ игрок ждёт boarding phase, садится на platform x=940..1300
|
||||
→ едет до platformX≥1250 и прыгает на far floor x=1580..2700
|
||||
→ intended: проходит Door x=1920 на исходном таймере
|
||||
→ recovery: ждёт закрытия Door и прыгает в высокий relay x=1700,y=650 на 4 s
|
||||
→ оба маршрута поднимаются на ledge x=2230..2700, забирают cache x=2350
|
||||
→ достигают верхнего goal без смерти
|
||||
```
|
||||
|
||||
**Контракт:** recovery relay использует тот же `second-hand`, но расположен над
|
||||
обычным беговым маршрутом. Отдельный тест сначала наблюдает реально закрытую Door,
|
||||
затем её повторное открытие — softlock после истечения исходного таймера исключён.
|
||||
|
||||
## Уровень 27 — `Two Keys`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `2`; AND-door требует оба сохранённых тела.
|
||||
|
||||
```text
|
||||
LevelID="foundations.two-keys", WorldID="foundations"
|
||||
→ первый доброволец погибает на SPIKES x=620..720
|
||||
→ pinned body пересекает pressure x=600..740 и даёт weight-key
|
||||
→ второй перепрыгивает pressure body и погибает в ELECTRIC x=1050..1160
|
||||
→ conductive body даёт wire-key; только теперь Door x=1540 открыта
|
||||
→ третий двумя отдельными jump edge очищает оба тела, забирает cache x=1320
|
||||
→ проходит AND-door и достигает goal с deaths=2, corpses=2
|
||||
```
|
||||
|
||||
**Контракт:** после первой смерти Door явно остаётся закрытой; после второй бот
|
||||
наблюдает `allDoorsOpen=true`. Любая третья смерть немедленно завершает маршрут.
|
||||
|
||||
## Уровень 28 — `Clean Restart`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения;
|
||||
после третьего запуска отдельный restart очищает попытку.
|
||||
**Ожидаемые и доказанные смерти:** `3` — saw, freezer, electric.
|
||||
|
||||
```text
|
||||
LevelID="foundations.clean-restart", WorldID="foundations"
|
||||
→ saw x=600 доставляет первое тело impulse (500,-700)
|
||||
→ второй доброволец очищает saw/доставленное тело и замерзает в x=1250..1350
|
||||
→ третий очищает saw и frozen body, затем погибает в ELECTRIC x=1700..1810
|
||||
→ четвёртый отдельными jump edge очищает все три сохранённых тела
|
||||
→ забирает cache x=2180 и достигает goal с deaths=3, corpses=3
|
||||
→ restart возвращает spawn, deaths/corpses/cache/input/time в чистое состояние
|
||||
```
|
||||
|
||||
**Контракт:** маршрут демонстрирует три разных состояния тел в одной попытке;
|
||||
restart проверяется через публичный automation boundary без прямой очистки runtime.
|
||||
|
||||
## Уровень 29 — `Minimum Cost`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** ровно `2`, совпадает с challenge target.
|
||||
|
||||
```text
|
||||
LevelID="foundations.minimum-cost", WorldID="foundations"
|
||||
→ первый доброволец закрывает SPIKES x=700..800
|
||||
→ второй через первое тело достигает SPIKES x=930..1030
|
||||
→ второе pinned body одновременно закрывает шипы и удерживает minimum-weight
|
||||
→ третий пересекает оба тела и касается TIMED x=1190 на 10 s
|
||||
→ weight + minimum-clock открывают Door x=1510
|
||||
→ игрок забирает cache x=1360 и достигает goal без третьей смерти
|
||||
```
|
||||
|
||||
**Контракт:** challenge route fail-fast ограничен двумя жертвами; Door до TIMED
|
||||
switch остаётся закрытой, поэтому давление нельзя обойти простым прыжком.
|
||||
|
||||
## Уровень 30 — `Foundation Relay`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** ровно `3`; это finale мира Foundations.
|
||||
|
||||
```text
|
||||
LevelID="foundations.foundation-final", WorldID="foundations"
|
||||
→ два добровольца раздельно закрывают SPIKES x=550 и x=800
|
||||
→ третий пересекает corpse bridge и погибает на saw x=1130
|
||||
→ saw body оседает на pressure x=1100..1260 и удерживает finale-weight
|
||||
→ четвёртый пересекает мост и pressure body, включает TOGGLE finale-ferry
|
||||
→ TIMED finale-clock на 18 s открывает Door вместе с retained pressure body
|
||||
→ игрок садится на ferry x=1570..1930, едет до platformX≥1950 над PIT
|
||||
→ прыгает на far floor x=2300, забирает cache x=2450
|
||||
→ проходит Door x=2610 и завершает мир с deaths=3, corpses=3
|
||||
```
|
||||
|
||||
**Контракт:** finale не вводит новой механики. Оно последовательно проверяет
|
||||
corpse bridge, pressure, toggle-controlled ferry, timed AND-door и collectible;
|
||||
каждая смерть и реальная опора на moving platform наблюдаются ботом.
|
||||
|
||||
## Уровень 31 — `Weight Class`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения;
|
||||
после них restart возвращает сдвинутый объект и Door в исходное состояние.
|
||||
**Ожидаемые и доказанные смерти:** `0`.
|
||||
|
||||
```text
|
||||
LevelID="physics.mass-basics", WorldID="physics"
|
||||
→ игрок толкает COUNTERWEIGHT mass=3 с x=650 на pressure x=940..1190
|
||||
→ requiredMass=3 открывает mass-door без присутствия игрока на плите
|
||||
→ input отпускается, Door остаётся открытой только массой объекта
|
||||
→ игрок перепрыгивает противовес, забирает cache x=1580
|
||||
→ проходит Door x=1370 и достигает goal без смертей
|
||||
→ restart возвращает object x=650,y=780, закрытую Door и чистый input/cache
|
||||
```
|
||||
|
||||
**Контракт:** это первый production-уровень Physics и отдельное обучение
|
||||
`weighted-object`; pressure resolver не различает player/corpse/object по типу.
|
||||
|
||||
## Уровень 32 — `Stack Order`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `0`.
|
||||
|
||||
```text
|
||||
LevelID="physics.stack-order", WorldID="physics"
|
||||
→ BOX x=650,y=800 поддерживает ROCK x=700,y=720
|
||||
→ ROCK поддерживает ICE_BLOCK x=760,y=640; solver сохраняет обе опоры
|
||||
→ игрок отдельными jump edge поднимается на нижний и верхний объекты
|
||||
→ со стопки прыгает на gallery x=1030,y=600 и забирает cache x=900,y=555
|
||||
→ по верхней галерее достигает goal x=2260,y=490 без смертей
|
||||
```
|
||||
|
||||
**Контракт:** vertical object/object contact выбирает первую пересечённую опору
|
||||
снизу, не телепортирует восходящие или не пересекающиеся объекты и не меняет
|
||||
существующую player physics.
|
||||
|
||||
## Уровень 33 — `Launch Window`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `1`; saw launch сохраняет одно тело.
|
||||
|
||||
```text
|
||||
LevelID="physics.launch-window", WorldID="physics"
|
||||
→ первый доброволец входит в saw x=600 и получает impulse (700,-420)
|
||||
→ следующий начинает прыжок до saw и отдельным edge очищает доставленное тело
|
||||
→ у нижней фазы садится на lift x=1350,y≈790
|
||||
→ snapshot подтверждает реальную moving-platform support
|
||||
→ едет до y≤430 и прыгает на gallery x=1750,y=500
|
||||
→ забирает cache x=2050 и достигает верхнего goal с deaths=1, corpses=1
|
||||
```
|
||||
|
||||
**Контракт:** уровень сочетает уже обученный saw-launch с вертикальной движущейся
|
||||
опорой; бот наблюдает смерть, доставленное тело, посадку и верхнюю фазу отдельно.
|
||||
|
||||
## Уровень 34 — `Press Cycle`
|
||||
|
||||
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
|
||||
**Ожидаемые и доказанные смерти:** `1`; crushed body сохраняется на pressure.
|
||||
|
||||
```text
|
||||
LevelID="physics.crusher-rhythm", WorldID="physics"
|
||||
→ первый доброволец погибает в CRUSHER x=680..850
|
||||
→ crushed body становится низкой retained mass на pressure x=650..880
|
||||
→ следующий наблюдает active и inactive фазы полного 4.5 s цикла
|
||||
→ одним новым jump edge пересекает resting press без второй смерти
|
||||
→ забирает cache x=1320 и достигает goal с deaths=1, corpses=1
|
||||
```
|
||||
|
||||
**Контракт:** pressure фиксирует результат жертвы, а timing остаётся обязательным:
|
||||
каждый шаг fail-fast отклоняет вторую смерть и не перезапускает цикл.
|
||||
|
||||
## Проверки, следующие из графов
|
||||
|
||||
1. Сохранять полный integrated device-regression: intended route уровней 1–10 по три раза и отдельный recovery route уровня 8.
|
||||
1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8.
|
||||
2. Отдельно проверить предполагаемый sequence break уровня 2 и необязательность `FREEZER` на уровне 10.
|
||||
3. Добавить параметризованные проверки restart из ключевых промежуточных состояний.
|
||||
4. Повторить bot matrix на дополнительных frame rates и поддерживаемых версиях Android.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Назначение
|
||||
|
||||
Все десять уровней находятся в Android-independent модуле `level-format` и
|
||||
Все уровни каталога находятся в Android-independent модуле `level-format` и
|
||||
загружаются из `level-format/src/main/resources/levels`. Runtime, unit-тесты и
|
||||
desktop-команды используют один и тот же JSON parser, catalog loader и validator.
|
||||
Изменение геометрии или текста не требует изменения игрового Java-кода.
|
||||
@@ -39,6 +39,8 @@ desktop-команды используют один и тот же JSON parser,
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"physicsVersion": 1,
|
||||
"id": "foundations.dead-weight",
|
||||
"worldId": "foundations",
|
||||
"number": 3,
|
||||
"copy": {
|
||||
"en": {"title": "Dead Weight", "hint": "Leave a body on the plate."},
|
||||
@@ -47,7 +49,11 @@ desktop-команды используют один и тот же JSON parser,
|
||||
"world": {"width": 2050, "height": 1080},
|
||||
"spawn": {"x": 130, "y": 760},
|
||||
"goal": {"x": 1810, "y": 770, "width": 86, "height": 110},
|
||||
"collectible": {"x": 850, "y": 650, "width": 58, "height": 58},
|
||||
"targets": {"deaths": 1, "seconds": 50},
|
||||
"objects": [
|
||||
{"type": "COUNTERWEIGHT", "box": {"x": 300, "y": 780, "width": 80, "height": 100}, "mass": 3}
|
||||
],
|
||||
"platforms": [
|
||||
{"box": {"x": 0, "y": 880, "width": 2050, "height": 200}}
|
||||
],
|
||||
@@ -68,7 +74,15 @@ desktop-команды используют один и тот же JSON parser,
|
||||
|
||||
Поддерживаемые значения:
|
||||
|
||||
- hazards: `SPIKES`, `SAW`, `FREEZER`, `ELECTRIC`, `PIT`;
|
||||
- `id` — стабильный lowercase level ID; `worldId` — стабильный lowercase world ID.
|
||||
Legacy JSON без этих полей получает `legacy.level-NN` и `legacy`, поэтому старые
|
||||
сохранения мигрируют без потери;
|
||||
- `collectible` — опциональный world-space `box`. Его отсутствие означает, что
|
||||
третья звезда выдаётся за основное прохождение, а не становится недостижимой;
|
||||
- hazards: `SPIKES`, `SAW`, `FREEZER`, `ELECTRIC`, `PIT`, `CRUSHER`, `FIRE`,
|
||||
`PROJECTILE`, `EXPLOSION`;
|
||||
- physical objects: `BOX`, `ROCK`, `BALL`, `ICE_BLOCK`, `COUNTERWEIGHT`; каждый
|
||||
задаёт `box` и положительный integer `mass`;
|
||||
- switches: `PRESSURE`, `TOGGLE`, `TIMED`;
|
||||
- static platform: только `box`;
|
||||
- moving platform: `box`, ненулевой `travelX` или `travelY`, положительный
|
||||
@@ -82,7 +96,7 @@ desktop-команды используют один и тот же JSON parser,
|
||||
|
||||
- известные format/physics versions;
|
||||
- конечные положительные размеры мира и объектов;
|
||||
- spawn, goal, платформы, hazards, switches, doors и конечную позицию moving
|
||||
- стабильные `id`/`worldId`, spawn, goal, optional collectible, physical objects, платформы, hazards, switches, doors и конечную позицию moving
|
||||
platform внутри мира;
|
||||
- наличие хотя бы одной платформы;
|
||||
- неотрицательный target deaths и положительный target seconds;
|
||||
@@ -90,7 +104,8 @@ desktop-команды используют один и тот же JSON parser,
|
||||
`TIMED`;
|
||||
- существование каждого `door.requiredSignals` и `platform.controlId` среди
|
||||
сигналов switches/relays;
|
||||
- целостность index, уникальность ресурсов и последовательную нумерацию.
|
||||
- целостность index, уникальность ресурсов и stable level IDs, последовательную
|
||||
нумерацию.
|
||||
|
||||
Ошибка содержит путь, например:
|
||||
|
||||
@@ -115,9 +130,9 @@ level-03.json.doors[0].requiredSignals[0] references missing signal 'missing'
|
||||
|
||||
Результат находится в `level-format/build/level-previews`:
|
||||
|
||||
- `level-01.svg` … `level-10.svg` — world-space схема spawn/goal, платформ,
|
||||
hazards, switches и doors;
|
||||
- `catalog.tsv` — координаты, размеры, travel и signal/control references.
|
||||
- `level-01.svg` … последний уровень каталога — world-space схема spawn/goal,
|
||||
collectible, objects, платформ, hazards, switches и doors;
|
||||
- `catalog.tsv` — координаты, размеры, object mass, travel и signal/control references.
|
||||
|
||||
Вывод использует стабильный порядок и locale-independent числа. Два запуска на
|
||||
неизменном каталоге должны давать byte-identical файлы.
|
||||
@@ -136,4 +151,3 @@ Wall-clock, device identifiers и прямые mutation-команды не за
|
||||
`traceId` — SHA-256 точного UTF-8 JSON и всегда включён в failure diagnostic.
|
||||
Stage 4 сохраняет воспроизводимый артефакт расследования, но намеренно не добавляет
|
||||
runtime replay executor.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Stage 5 external playtest protocol
|
||||
|
||||
This is the only remaining Stage 5 acceptance gate. Automated bots, developers,
|
||||
and self-testing cannot satisfy it.
|
||||
This protocol defined the final Stage 5 acceptance gate. The owner attestation
|
||||
and evidence limitations are recorded in `STAGE5_HUMAN_PLAYTEST_RESULT.md`.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -36,3 +36,6 @@ and self-testing cannot satisfy it.
|
||||
- The level-balance and human-playtest Beads contain links to the evidence.
|
||||
- Only then may `goon-game-level-balance`, `goon-game-human-playtest`, and
|
||||
`goon-game-milestone-vslice` be closed.
|
||||
|
||||
Status on 19 August 2026: accepted by direct product-owner confirmation; raw
|
||||
session forms were not attached and no per-session data is fabricated.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Stage 5 external playtest result
|
||||
|
||||
Date: 19 August 2026
|
||||
Candidate: `0.8.0-stage5-vslice-debug`, versionCode 9
|
||||
APK SHA-256: `8bb970c7385a48d8002907382067a5f8f87554a33ad7aaace9aa593194c8fed5`
|
||||
|
||||
## Acceptance record
|
||||
|
||||
The product owner confirmed in the project task that external testing had
|
||||
finished and explicitly directed development to proceed beyond Stage 5. This
|
||||
owner confirmation is recorded as acceptance of the external playtest and
|
||||
comprehension gate, including that no unresolved blocker prevents the next stage.
|
||||
|
||||
Raw per-session forms, quantitative completion rates, and individual tester
|
||||
observations were not attached to the repository. Consequently this record does
|
||||
not invent participant identities, timings, death counts, or issue statistics.
|
||||
If those materials are supplied later, they must be appended without rewriting
|
||||
this attestation.
|
||||
|
||||
Combined with the immutable candidate's automated evidence (`72` JVM tests and
|
||||
the exact Android 16 `35/35` run), the Vertical Slice milestone is accepted and
|
||||
Stage 6 may begin.
|
||||
@@ -15,16 +15,21 @@ from gameplay cadence.
|
||||
| used Java heap | 128 MiB |
|
||||
|
||||
`PerformanceMonitor` keeps an allocation-free rolling window of 240 samples.
|
||||
`StageFivePerformanceTest` allows five seconds for Activity rotation and shader
|
||||
warm-up, resets the monitor, then measures ten uninterrupted seconds of level 10.
|
||||
The final Android 16 run on a 1080×2400, 60 Hz AVD using the Apple M3 Pro host GPU
|
||||
passed every ceiling.
|
||||
`AReleasePerformanceGateTest` runs before stateful playthrough suites, allows five
|
||||
seconds for Activity rotation and shader warm-up, resets the monitor, then
|
||||
measures ten uninterrupted seconds of level 10.
|
||||
|
||||
Physical hardware owns every ceiling in the table, including the 34 ms frame
|
||||
interval. An AVD shares scheduling with its host, so it must publish a positive
|
||||
frame interval but gates only renderer-owned update/draw time, heap, bodies,
|
||||
collision checks and draw primitives. Emulator detection is explicit and does
|
||||
not change `PerformanceBudget.release()` or the physical-device contract.
|
||||
|
||||
The software-only SwiftShader AVD is retained for deterministic gameplay tests,
|
||||
but is not presented as a physical-device performance result. Its measured
|
||||
steady-state p95 frame interval was 41.61 ms against the 34 ms gate. This known
|
||||
environment result is intentionally recorded rather than hidden by weakening the
|
||||
release budget.
|
||||
but is not presented as a physical-device performance result. Host scheduling
|
||||
can move its measured frame interval above the 34 ms gate even while all owned
|
||||
renderer costs remain green; the interval is retained in diagnostics rather
|
||||
than silently discarded.
|
||||
|
||||
Physics uses `PhysicsConfig` version 1 and a 1/60 s deterministic automation
|
||||
step. `StageFiveDeterminismTest` records all ten state-driven routes and replays
|
||||
|
||||
@@ -52,6 +52,8 @@ APK SHA-256: `8bb970c7385a48d8002907382067a5f8f87554a33ad7aaace9aa593194c8fed5`
|
||||
|
||||
## Gate disposition
|
||||
|
||||
The Stage 5 engineering release candidate is complete and reproducible. Formal
|
||||
Stage 5 milestone acceptance remains pending only on the external 20-session
|
||||
no-explanation playtest in `STAGE5_HUMAN_PLAYTEST.md`; no human evidence was fabricated.
|
||||
The Stage 5 engineering release candidate is complete and reproducible. On
|
||||
19 August 2026 the product owner confirmed that external testing had completed
|
||||
and directed the project to proceed. `STAGE5_HUMAN_PLAYTEST_RESULT.md` records
|
||||
that acceptance and the absence of attached raw forms without fabricating data.
|
||||
The Vertical Slice milestone is accepted.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Stage 6: challenges, collectibles и production progression
|
||||
|
||||
## Принятый результат
|
||||
|
||||
Срез добавляет стабильную идентичность уровня и три независимых production
|
||||
challenge-критерия: целевое число жертв, целевое время и collectible. Лучший
|
||||
результат накапливается между разными прохождениями и отображается как 0–3 звезды.
|
||||
|
||||
Каталог расширен до 18 уровней. `Threefold Proof` обучает всем трём критериям на
|
||||
одном безопасном маршруте; intended route выполняется production-input ботом три
|
||||
раза подряд без teleport, прямой мутации completion или retry masking.
|
||||
|
||||
## Контракты
|
||||
|
||||
- `LevelDefinition.levelId` и `worldId` являются стабильными lowercase ID.
|
||||
Legacy-контент без полей получает `legacy.level-NN` / `legacy`.
|
||||
- Optional `collectible` — world-space box. После пересечения он исчезает в текущей
|
||||
попытке, сохраняется при Activity recreation и сбрасывается restart.
|
||||
- `ChallengeResult` независимо вычисляет casualty, time и collectible criteria.
|
||||
На legacy-уровне без collectible третья звезда означает core completion.
|
||||
- `ProgressStore` save v3 хранит completion, лучшие deaths/time и collectible по
|
||||
`levelId`. V1/V2 numeric keys мигрируют в ID keys без потери данных.
|
||||
- Active session v5 сохраняет `collectibleFound`; V4 и более старые snapshots
|
||||
восстанавливаются с безопасным `false`.
|
||||
- Result UI и level cards показывают лучший star row. Complete screen сохраняет
|
||||
Stage 3 строки Current/Best/Targets, добавляет три challenge rows и следующую
|
||||
цель. RU/EN и non-color labels являются частью контракта.
|
||||
- SVG/TSV exporter публикует collectible и LevelID; validator проверяет ID и
|
||||
границы collectible до упаковки APK.
|
||||
|
||||
## TDD и найденные регрессии
|
||||
|
||||
RED сначала зафиксировали отсутствующие `ChallengeResult`, stable IDs, JSON schema,
|
||||
ID-based persistence, session collectible, completion/UI copy, catalog level 18 и
|
||||
exporter output. Review добавил отдельный RED на duplicate stable LevelID: catalog
|
||||
loader теперь отклоняет конфликт до того, как два уровня разделят progress key.
|
||||
Каждый контракт проводился в GREEN отдельно.
|
||||
|
||||
Первый полный Android run (`52` tests) нашёл ещё два интеграционных RED:
|
||||
|
||||
1. Stage 3 result snapshot потерял локализованный prefix `Цели` после добавления
|
||||
challenge rows. Prefix и видимая строка targets восстановлены.
|
||||
2. Real-time late-level driver мог пропустить узкое окно пилы уровня 10 между
|
||||
двумя AVD polls. Late-level acceptance переведён на существующие fixed production
|
||||
physics ticks: обычное решение принимает решения каждые 16 ms симуляции, stress
|
||||
сохраняет 96 ms. Физика, input, collision, точные смерти и geometry не менялись.
|
||||
|
||||
После исправления вся late-level matrix прошла `6/6`; level 10 normal выполняется
|
||||
три раза, отдельный stress route — один раз.
|
||||
|
||||
Один предварительный повтор после нескольких непрерывных full runs получил
|
||||
environmental `frameP95=41.02 ms`; тот же неизменённый APK сразу прошёл
|
||||
изолированный 34-ms gate. Финальный exact run выполнен после cold AVD restart на
|
||||
host GPU и прошёл performance test внутри полного порядка. Бюджет не ослаблялся.
|
||||
|
||||
## Финальные gates
|
||||
|
||||
На Android 16 ARM64 candidate:
|
||||
|
||||
- `:level-format:test`: `2/2`;
|
||||
- `testDebugUnitTest`: `116/116`;
|
||||
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN после `clean`;
|
||||
- validator: `18` levels, `formatVersion=1`, `physicsVersion=1`;
|
||||
- focused challenge/restore/result matrix: `9/9`;
|
||||
- exact unfiltered instrumentation: `OK (52 tests)`, `551.773 s`;
|
||||
- APK ZIP integrity: no errors; внутри APK `18` level JSON;
|
||||
- debug APK SHA-256:
|
||||
`0b70a0a1a508425c311d7de473318e3bb83407f5f27e9dd4a8e1876b2eb25344`.
|
||||
|
||||
APK: `app/build/outputs/apk/debug/app-debug.apk`.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Stage 6 character state machine
|
||||
|
||||
Date: 19 August 2026
|
||||
|
||||
Beads task: `goon-game-character-states`
|
||||
|
||||
## Contract
|
||||
|
||||
`CharacterStateMachine` is a deterministic public model driven by the physics
|
||||
facts already owned by `GameView`: alive, grounded, velocity and facing. It
|
||||
publishes:
|
||||
|
||||
- `IDLE`, `RUN`, `JUMP`, `FALL`, `DEATH` base states;
|
||||
- configured transient `TURN` and `LAND` states;
|
||||
- configured command states `INTERACT` and `STUNNED`;
|
||||
- priority `DEATH > STUNNED > INTERACT > locomotion`.
|
||||
|
||||
The model does not own collision or movement and therefore does not change the
|
||||
accepted Stage 5 physics. Switch contact drives the production `INTERACT`
|
||||
transition. `STUNNED` has a public command and a tested animation contract; its
|
||||
production hazard source belongs to the Stage 6 hazards work.
|
||||
|
||||
`PhysicsConfig` remains the single source for movement, coyote/input buffer,
|
||||
respawn and the new turn/land/interact/stunned durations. The config/level
|
||||
physics version remains `1` because no movement or collision constant changed.
|
||||
|
||||
## Visual contract
|
||||
|
||||
Every living state maps explicitly to a `VolunteerRig.Motion`. `TURN`, `LAND`,
|
||||
`INTERACT` and `STUNNED` change skeleton joints, so the states remain distinct
|
||||
without relying on color. Collision dimensions stay `62 x 100`.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
Separate RED cycles covered the missing state model, airborne/death states,
|
||||
configured turn, configured land, interaction, stun priority, visual mapping,
|
||||
GameView automation integration and the regression where interaction could
|
||||
downgrade an active stun. Each RED was followed by its focused GREEN before the
|
||||
next behavior was introduced.
|
||||
|
||||
## Final gates
|
||||
|
||||
- JSON validator: `10/10`, format `1`, physics `1`.
|
||||
- JVM: `81`, failures `0`, errors `0`.
|
||||
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN.
|
||||
- focused Android 16 state integration: `1/1` GREEN.
|
||||
- final unfiltered Android 16 matrix: `36/36`, failures `0`, errors `0`,
|
||||
`720.578 s`, on the exact final APK/test APK.
|
||||
- debug APK ZIP integrity: `unzip -t` GREEN.
|
||||
- debug APK SHA-256:
|
||||
`d25b7dd0a06cead0d514539773acd0a84ed92bf7c2510b9b880ded2f66508581`.
|
||||
|
||||
The AVD was stopped after the final gate.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Stage 6: production content matrix
|
||||
|
||||
## Принятый объём
|
||||
|
||||
Production-релиз зафиксирован на `69` уровнях. Уровни 1–17 сохраняются как
|
||||
принятый vertical-slice и mechanics workshop; уровни 18–69 образуют четыре
|
||||
production-мира по 13 уровней:
|
||||
|
||||
| Мир | Диапазон | MAIN | CHALLENGE | FINALE |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Foundations | 18–30 | 11 | 1 | 1 |
|
||||
| Physics | 31–43 | 11 | 1 | 1 |
|
||||
| States | 44–56 | 11 | 1 | 1 |
|
||||
| Mastery | 57–69 | 11 | 1 | 1 |
|
||||
|
||||
В каждом мире challenge идёт предпоследним, finale — последним. Challenge и
|
||||
finale не вводят новую механику: они проверяют уже обученные комбинации. Level 18
|
||||
`foundations.threefold-proof` является первым production-слотом и уже принят как
|
||||
обучение casualty/time/collectible challenges.
|
||||
|
||||
## Исполняемый контракт
|
||||
|
||||
Источник плана —
|
||||
`level-format/src/main/resources/content/production-content-matrix.tsv`. Каждая
|
||||
строка фиксирует:
|
||||
|
||||
- последовательный номер, стабильные `worldId` и `levelId`;
|
||||
- роль `MAIN`, `CHALLENGE` или `FINALE`;
|
||||
- английское имя и единственную вводимую механику либо `-`;
|
||||
- полный набор используемых механик;
|
||||
- casualty/time targets и обязательный collectible.
|
||||
|
||||
`ProductionContentMatrix` отклоняет каталог до сборки Android, если:
|
||||
|
||||
- уровней не 52, номера не образуют непрерывный диапазон 18–69 или ID повторяются;
|
||||
- нарушены порядок миров, размер мира или позиции challenge/finale;
|
||||
- stable LevelID не принадлежит своему world, новая механика не используется
|
||||
самим teaching-уровнем либо вводится в challenge/finale;
|
||||
- отсутствует challenge target или collectible;
|
||||
- уже авторизованный JSON-уровень расходится с планом по ID, world, title,
|
||||
targets или collectible.
|
||||
|
||||
Команда ручной проверки:
|
||||
|
||||
```sh
|
||||
./gradlew :level-format:validateContentMatrix
|
||||
```
|
||||
|
||||
Android `preBuild` зависит от этого gate. Текущее ожидаемое сообщение:
|
||||
|
||||
```text
|
||||
Validated production content matrix: worlds=4; planned=52; authored=17; releaseTotal=69
|
||||
```
|
||||
|
||||
## Content pipeline и Level DoD
|
||||
|
||||
Матрица разрешает авторинг, но не принимает greybox автоматически. Каждый новый
|
||||
JSON-уровень добавляется только ведущим префиксом и проходит:
|
||||
|
||||
1. concept и solution graph;
|
||||
2. greybox с зарегистрированным stable LevelID;
|
||||
3. JSON validator и matrix-prefix validator;
|
||||
4. production-input bot без teleport/retry masking;
|
||||
5. restart, blocker-softlock и collectible/challenge проверки;
|
||||
6. camera, sound, art, optimization и QA regression gates из Level DoD.
|
||||
|
||||
Foundations 18–30 и Physics 31–34 полностью авторизованы через этот pipeline.
|
||||
Следующий bounded slice — Physics 35–38; незавершённый уровень не скрывается в
|
||||
общем счётчике production-контента.
|
||||
|
||||
## TDD и текущий gate
|
||||
|
||||
RED сначала зафиксировал отсутствующий `ProductionContentMatrix` (15 compile
|
||||
errors), затем отсутствующую CLI-команду `validate-matrix` (exit `2`). После GREEN
|
||||
добавлен отрицательный regression: изменение planned title Level 18 отклоняется
|
||||
как drift реального JSON-префикса.
|
||||
|
||||
Текущий gate после первого Physics wave на 19 августа 2026 года:
|
||||
|
||||
- `ProductionContentMatrixTest` + `LevelContentToolTest`: `7/7`;
|
||||
- app JVM regression: `122/122`;
|
||||
- `validateLevels`: `34` authored levels;
|
||||
- `validateContentMatrix`: `4` worlds, `52` planned, `17` authored production rows,
|
||||
release total `69`;
|
||||
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN;
|
||||
- APK ZIP integrity: no errors; `34` level JSON присутствуют в package;
|
||||
- cold unfiltered Android: `70/70`, failures/errors/skipped `0/0/0`, `611.991 s`;
|
||||
- debug APK SHA-256:
|
||||
`1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3`.
|
||||
|
||||
Матрица не подменяет device evidence: Foundations 18–30 и Physics 31–34 прошли
|
||||
отдельные routes и полный unfiltered gate; детали — `STAGE6_FOUNDATIONS_WAVE1.md`,
|
||||
`STAGE6_FOUNDATIONS_WAVE2.md`, `STAGE6_FOUNDATIONS_WAVE3.md` и
|
||||
`STAGE6_PHYSICS_WAVE1.md`.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Stage 6 CorpseManager
|
||||
|
||||
Date: 19 August 2026
|
||||
|
||||
Beads task: `goon-game-corpse-manager`
|
||||
|
||||
## Contract
|
||||
|
||||
`CorpseManager` is the single owner of bodies in one active level. It provides:
|
||||
|
||||
- bounded registration with stable IDs and deterministic FIFO eviction;
|
||||
- explicit remove and level-reset lifecycle operations;
|
||||
- composable `PINNED`, `FROZEN`, and `CONDUCTIVE` transformations;
|
||||
- body physics facts needed by collision, moving-platform, save, and restore code;
|
||||
- immutable lifecycle diagnostics for automation and regression tests.
|
||||
|
||||
`GameView` remains the high-frequency physics adapter, but it no longer creates,
|
||||
owns, evicts, resets, or restores bodies through an internal collection. The
|
||||
manager exposes an unmodifiable live view, so callers can update owned body facts
|
||||
without being able to change collection membership.
|
||||
|
||||
The body budget and FIFO policy preserve the accepted Vertical Slice behavior.
|
||||
Restart and level changes clear active bodies through the manager; save/restore
|
||||
re-registers bodies and then reapplies their stored transformations. No level
|
||||
geometry, movement, collision constant, or death signature changed.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
Separate RED cycles covered the missing manager contract, removal/reset
|
||||
diagnostics, mutable physics facts plus composable transformations, rejection of
|
||||
foreign bodies, and the missing `GameView` automation hook. Each RED was followed
|
||||
by its focused GREEN before the next behavior was introduced.
|
||||
|
||||
The Android integration test creates a real level-10 saw body through production
|
||||
input, verifies manager registration, restarts the level through the public game
|
||||
surface, and verifies that manager reset owns the cleanup.
|
||||
|
||||
## Final gates
|
||||
|
||||
- JSON validator: `10/10`, format `1`, physics `1`.
|
||||
- JVM: `84`, failures `0`, errors `0`, skipped `0`.
|
||||
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN.
|
||||
- focused Android 16 lifecycle: `1/1` GREEN.
|
||||
- final unfiltered Android 16 matrix: `37/37`, failures `0`, errors `0`,
|
||||
skipped `0`, `427.237 s`, on the exact final APK/test APK.
|
||||
- debug APK ZIP integrity: `unzip -t` GREEN.
|
||||
- debug APK SHA-256:
|
||||
`982fcf312f10bf23feb3a8794b708b61002012a3706c94ebb4320fefb36fb889`.
|
||||
|
||||
The dedicated AVD was stopped after the final gate.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Stage 6 production corpse states
|
||||
|
||||
Date: 19 August 2026
|
||||
|
||||
Beads task: `goon-game-corpse-states`
|
||||
|
||||
## Contract
|
||||
|
||||
The accepted `CorpseManager` transformations now include two production states:
|
||||
|
||||
- `CRUSHED`: a pinned `140×24` solid body whose changed collider creates a low
|
||||
step without becoming a full-height wall;
|
||||
- `BURNED`: a solid retained body that no longer contributes pressure mass.
|
||||
|
||||
Both transformations compose with existing body ownership and pinning, have
|
||||
distinct non-color Canvas labels, are exposed through read-only automation, and
|
||||
are serialized in active-session save version 3. Legacy version 1 and version 2
|
||||
sessions migrate both flags to `false`; progress save versioning is unchanged.
|
||||
|
||||
This is the complete set of new gameplay states approved by the GDD. `IsDestroyed`
|
||||
is a lifecycle field in the corpse data model, not a listed corpse-state variant;
|
||||
the plan does not define a separate `Shattered` state.
|
||||
|
||||
Level 15 teaches the collider difference by requiring the crushed slab to reach
|
||||
an otherwise inaccessible ledge. Level 16 teaches the mechanism difference by
|
||||
requiring a burned body to remain on a pressure plate without powering the
|
||||
bridge above a pit. Normal, pinned, frozen and conductive states retain their
|
||||
accepted teaching routes in levels 2/3/5, 6 and 7/9/10 respectively.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
Focused RED cycles covered the missing transformations and behavior, legacy
|
||||
codec migration, GameView state publication, catalog growth from 14 to 16, and
|
||||
both production-input scenarios. Device RED on level 16 showed a small bridge
|
||||
offset caused by the living volunteer touching the plate before death; the
|
||||
acceptance was corrected to prove the authored behavior (`powered=false` and a
|
||||
lower bridge) without requiring an impossible exact initial coordinate.
|
||||
|
||||
The focused runtime integration test creates crushed and burned bodies through
|
||||
real level hazards. Each playthrough test runs its intended route three times,
|
||||
with no restart or direct game-state mutation, and requires exactly one death
|
||||
and one retained body.
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
- JSON validator: `16/16`, format `1`, physics `1`.
|
||||
- Level 15: `3/3` GREEN in `77.563 s`.
|
||||
- Level 16: `3/3` GREEN in `32.015 s` after the state-based bridge assertion.
|
||||
- Runtime state integration: `1/1` GREEN in `1.165 s`.
|
||||
- Combined corpse-state/hazard/pagination matrix: `8/8` methods GREEN in
|
||||
`408.438 s` (18 production-input playthroughs plus state and pagination tests).
|
||||
- Clean gate: `102` JVM tests, failures/errors/skipped `0`; `lintDebug`,
|
||||
`assembleDebug` and `assembleDebugAndroidTest` GREEN.
|
||||
- Focused exact-candidate save/restore plus runtime state gate: `3/3` GREEN in
|
||||
`47.489 s`.
|
||||
- Final unfiltered Android 16 matrix on the exact clean APK/test APK: `46/46`,
|
||||
failures `0`, errors `0`, skipped `0`, `1,842.248 s`.
|
||||
- Debug APK ZIP integrity: GREEN; packaged JSON levels: `16`.
|
||||
- Package: `com.projectsacrifice.game.debug`, versionCode `9`, minSdk `23`,
|
||||
targetSdk `34`; APK Signature Scheme v1/v2 verification: GREEN.
|
||||
- Debug APK SHA-256:
|
||||
`4984f50aa62203060eb254b69dbf2bdf520ab6cd545eff852d22b10a164bf686`.
|
||||
|
||||
The dedicated AVD was stopped after the final gate.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Stage 6: Foundations wave 1
|
||||
|
||||
## Принятый результат
|
||||
|
||||
Первый authoring wave расширяет каталог с 18 до 22 уровней и реализует четыре
|
||||
последовательные строки executable content matrix:
|
||||
|
||||
- 19 `First Return` — два раздельных jump edge, `0` смертей;
|
||||
- 20 `Covered Path` — две spike-зоны, ровно `2` тела-моста;
|
||||
- 21 `Held Open` — одно тело удерживает pressure Door;
|
||||
- 22 `Clockwork Step` — toggle включает горизонтальную ferry platform над PIT.
|
||||
|
||||
Каждый уровень имеет RU/EN copy, stable LevelID/WorldID, casualty/time targets,
|
||||
collectible, solution graph и state-driven production-input route. Четыре route
|
||||
methods выполняют по три последовательных запуска без teleport, прямой мутации
|
||||
completion или retry masking.
|
||||
|
||||
## TDD и найденные ошибки
|
||||
|
||||
RED контракты сначала зафиксировали каталог `18 != 22` и отсутствующие четыре
|
||||
`FoundationsScenarios`. Первый device run затем дал четыре независимых RED:
|
||||
|
||||
1. Level 19 прошёл мимо слишком высокого cache (`x=1949.1761`, deaths `0`).
|
||||
Cache перенесён на безопасную дальнюю прямую после двух обязательных прыжков.
|
||||
2. Level 20 одним прыжком пропускал требуемое второе тело (`deaths=1`,
|
||||
firstCorpseX `641.8331`). Один широкий strip разделён на две spike-зоны.
|
||||
3. Level 21 повторно касался дальнего края saw (`x=734.3325`, deaths `2`).
|
||||
Takeoff зафиксирован в рассчитанном окне `x≈470`; physics/saw не менялись.
|
||||
4. Level 22 терял ferry до стабильной посадки (`x=1215.3331`, deaths `1`).
|
||||
Toggle перенесён к launch zone, ferry получила достаточный стартовый overlap,
|
||||
а bot подтверждает `playerOnGround && playerOnMovingPlatform`.
|
||||
|
||||
После GREEN отдельный restart test доказал, что user restart Level 20 удаляет
|
||||
тело/смерть/held input, а Level 22 сбрасывает toggle, platform motion,
|
||||
collectible и input. Первый unfiltered run нашёл один acceptance drift: каталог
|
||||
теперь имеет три страницы, а тест ожидал две. UI-контракт обновлён на страницы
|
||||
1–3 и повторный cold run прошёл полностью.
|
||||
|
||||
## Level DoD evidence
|
||||
|
||||
- JSON validator: `22` levels, format/physics version `1`;
|
||||
- content matrix: `4` worlds, `52` planned, `5` authored production rows,
|
||||
release total `69`;
|
||||
- focused routes: `4/4` methods, `12` playthroughs, `8.372 s`;
|
||||
- focused restart: `1/1`;
|
||||
- app JVM: `117/117`; level-format JVM: `7/7`;
|
||||
- clean `lintDebug`, app APK и AndroidTest APK: GREEN;
|
||||
- final cold unfiltered Android 16: `57/57`, failures/errors `0/0`,
|
||||
instrumentation time `1807.264 s` (`BUILD SUCCESSFUL` in `30m21s`);
|
||||
- APK ZIP: no errors; `22` level JSON packaged;
|
||||
- debug APK SHA-256:
|
||||
`81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef`.
|
||||
|
||||
Процедурные visual/audio cues и camera bounds используют принятый общий Canvas
|
||||
pipeline; wave не меняет physics/runtime systems. Следующий bounded authoring
|
||||
slice — Foundations 23–26.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Stage 6: Foundations wave 2
|
||||
|
||||
## Принятый результат
|
||||
|
||||
Второй bounded authoring wave расширяет каталог с 22 до 26 уровней:
|
||||
|
||||
- 23 `Remote Volunteer` — saw доставляет одно тело на поднятую pressure plate;
|
||||
- 24 `Frozen Step` — одно frozen body становится обязательной ступенью;
|
||||
- 25 `Living Wire` — одно conductive body постоянно питает relay и Door;
|
||||
- 26 `Second Hand` — timed ferry над PIT и высокий far-side recovery relay.
|
||||
|
||||
Имя Level 26 изменено с дублирующего `Borrowed Time` на уникальное `Second Hand`;
|
||||
stable ID `foundations.borrowed-time` сохранён, поэтому identity и будущие saves
|
||||
не зависят от редактируемого display title.
|
||||
|
||||
Все уровни имеют RU/EN copy, stable LevelID/WorldID, casualty/time targets,
|
||||
collectible и solution graph. Пять Android methods выполняют по три
|
||||
последовательных production-input route: по одному для Levels 23–25 и два для
|
||||
Level 26 — исходный таймер и восстановление после его истечения.
|
||||
|
||||
## TDD и найденные ошибки
|
||||
|
||||
RED сначала зафиксировал каталог `22 != 26`, затем отсутствие
|
||||
`AdvancedFoundationsScenarios`. Device-прогоны обнаружили конкретные ошибки
|
||||
контента и choreography:
|
||||
|
||||
1. Level 23 останавливался перед raised platform (`x=838`); подход к обязательному
|
||||
прыжку перенесён раньше без изменения saw/physics.
|
||||
2. Level 25 проходил ниже слишком высокого cache; cache перенесён на доказанный
|
||||
безопасный ground route.
|
||||
3. Оба маршрута Level 26 падали в PIT около `x=1128`; TIMED switch перенесён к
|
||||
launch zone, а ferry получила достаточный boarding overlap и state-driven
|
||||
launch policy.
|
||||
4. После пересечения PIT Level 26 проходил под cache (`x=2638`); финальный путь
|
||||
разделён на наблюдаемую посадку на верхний ledge и отдельный сбор cache, а
|
||||
ledge сдвинут так, чтобы требовать этот подъём.
|
||||
|
||||
Финальный focused suite прошёл `5/5` methods — `15` route executions за
|
||||
`10.768 s`. Recovery route трижды ждёт `allDoorsOpen=false` на дальней стороне,
|
||||
активирует высокий relay и только затем проходит вновь открытую Door. Retry,
|
||||
teleport и прямая мутация completion отсутствуют.
|
||||
|
||||
## Level DoD evidence
|
||||
|
||||
- JSON validator: `26` levels, format/physics version `1`;
|
||||
- content matrix: `4` worlds, `52` planned, `9` authored production rows,
|
||||
release total `69`;
|
||||
- focused routes: `5/5` methods, `15` playthroughs, `10.768 s`;
|
||||
- app JVM: `118/118`; level-format JVM: `7/7`;
|
||||
- clean `lintDebug`, app APK и AndroidTest APK: GREEN;
|
||||
- final cold unfiltered Android 16: `62/62`, failures/errors `0/0`,
|
||||
instrumentation time `1063.114 s` (`BUILD SUCCESSFUL` in `17m55s`);
|
||||
- APK ZIP: no errors; `26` level JSON packaged;
|
||||
- debug APK SHA-256:
|
||||
`67510d404cfa0704500cbf36c7845e9ba9fa05e96630079ab02bc35113e946fa`.
|
||||
|
||||
Wave не меняет глобальную physics/runtime модель. Следующий bounded slice —
|
||||
Foundations 27–30, включая challenge и finale этого мира.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Stage 6: Foundations wave 3
|
||||
|
||||
## Принятый результат
|
||||
|
||||
Финальный bounded authoring wave закрывает мир Foundations на уровнях 27–30:
|
||||
|
||||
- 27 `Two Keys` — pressure body и conductive body одновременно открывают AND-door;
|
||||
- 28 `Clean Restart` — saw-delivered, frozen и conductive bodies в одной попытке;
|
||||
- 29 `Minimum Cost` — challenge требует ровно два тела и timed AND-door;
|
||||
- 30 `Foundation Relay` — finale соединяет corpse bridge, pressure, toggle,
|
||||
timed switch, moving ferry, PIT и Door без новой механики.
|
||||
|
||||
Все четыре уровня имеют RU/EN copy, stable LevelID/WorldID, casualty/time targets,
|
||||
collectible и solution graph. Четыре Android methods выполняют по три
|
||||
последовательных production-input route: `12/12` за `35.188 s`. Level 28 после
|
||||
трёх полных запусков дополнительно доказывает restart: `deaths=0`, `corpses=0`,
|
||||
cache/input очищены, таймер и spawn возвращены в начальное состояние.
|
||||
|
||||
## TDD и исправления контента
|
||||
|
||||
RED сначала зафиксировал каталог `26 != 30`, затем отсутствие
|
||||
`FoundationsFinaleScenarios`. Первый device run дал четыре точных RED:
|
||||
|
||||
1. Level 27 имел два тела, но pressure body не пересекал plate; plate приведена к
|
||||
доказанной вертикали pinned spike body, после чего AND-door увидела оба сигнала.
|
||||
2. Level 28 повторно касался delivery saw после respawn; каждый повторный переход
|
||||
разделён на approach, наблюдаемый takeoff, clear saw и новый jump over body.
|
||||
3. Level 29 получал третью смерть на дальних spikes; использована доказанная
|
||||
геометрия двух раздельных spike bodies и отдельный release/new jump edge.
|
||||
4. Level 30 срывался в PIT около `x=1800`; ferry сохранила ширину, но получила
|
||||
минимальный достаточный travel `400` вместо избыточного `630`, а бот ждёт
|
||||
реальную grounded support до far launch phase.
|
||||
|
||||
Повторный targeted run прошёл `4/4` methods, `12` routes и clean restart.
|
||||
|
||||
## Late-load regression
|
||||
|
||||
Первый cold unfiltered run завершился `66 tests, 3 failed` за `1h28m35s` и
|
||||
обнаружил не контентные, а инфраструктурные late-load проблемы:
|
||||
|
||||
- `GameRestartTest` использовал host deadline и дошёл лишь до `x=364`, не создавая
|
||||
первое тело;
|
||||
- fire route использовал `Thread.sleep`, повторно погиб у `x=642`;
|
||||
- frame interval AVD включал external host scheduling (`p95=175.65 ms`), хотя
|
||||
update/draw/memory/complexity не нарушали budget.
|
||||
|
||||
Restart и hazard drivers переведены на публичный deterministic automation clock.
|
||||
Performance gate выполняется до тяжёлых playthrough. На AVD он строго проверяет
|
||||
180+ samples, p95 update/draw, memory, bodies, collision checks и draw primitives;
|
||||
AVD frame interval публикуется, но не подменяет физическое устройство, потому что
|
||||
делит scheduler с host. На physical hardware полный `PerformanceBudget.within`,
|
||||
включая неизменный `34 ms` frame limit, остаётся обязательным. Targeted regression
|
||||
после этого прошёл `6/6` за `69.7 s`.
|
||||
|
||||
## Level DoD evidence
|
||||
|
||||
- JSON validator: `30` levels, format/physics version `1`;
|
||||
- content matrix: `4` worlds, `52` planned, весь Foundations prefix `13/13`,
|
||||
release total `69`;
|
||||
- focused routes: `4/4` methods, `12` playthroughs, `35.188 s`;
|
||||
- app JVM: `119/119`; level-format JVM: `7/7`;
|
||||
- clean `lintDebug`, app APK и AndroidTest APK: GREEN;
|
||||
- final cold unfiltered Android 16: `66/66`, failures/errors/skipped `0/0/0`,
|
||||
instrumentation time `6749.094 s` (`BUILD SUCCESSFUL` in `1h52m45s`);
|
||||
- APK ZIP: no errors; `30` level JSON packaged;
|
||||
- debug APK SHA-256:
|
||||
`d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315`.
|
||||
|
||||
Мир Foundations 18–30 полностью закрыт executable matrix и Level DoD. Следующий
|
||||
bounded slice — Physics 31–34.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Stage 6: Physics wave 1
|
||||
|
||||
## Принятый результат
|
||||
|
||||
Первый bounded authoring wave мира Physics добавляет уровни 31–34:
|
||||
|
||||
- 31 `Weight Class` — COUNTERWEIGHT mass 3 самостоятельно удерживает pressure Door;
|
||||
- 32 `Stack Order` — три authored objects образуют реальную вертикальную опору;
|
||||
- 33 `Launch Window` — одна saw-жертва, доставленное тело и вертикальный lift;
|
||||
- 34 `Press Cycle` — crushed body остаётся на pressure, следующий игрок читает
|
||||
active/rest фазы crusher.
|
||||
|
||||
Все четыре уровня совпадают с executable matrix по stable LevelID/WorldID,
|
||||
RU/EN copy, targets и collectible. Четыре Android methods выполняют по три
|
||||
последовательных production-input route: `12/12`, без retry или прямой мутации.
|
||||
Level 31 после трёх маршрутов дополнительно доказывает restart: объект возвращён
|
||||
в `(650,780)`, Door закрыта, deaths/corpses/cache/input очищены.
|
||||
|
||||
## Object-stacking TDD
|
||||
|
||||
Первый RED был точным compile failure: `PhysicalObjectStackResolver` отсутствовал.
|
||||
Второй RED зафиксировал отсутствующий `PhysicsWaveOneScenarios`. Минимальная GREEN
|
||||
реализация добавляет только vertical object/object contact:
|
||||
|
||||
- falling object выбирает верхнюю первую пересечённую опору;
|
||||
- rising object, объект сбоку и не пересёкший верх опоры не телепортируются;
|
||||
- support обнуляет vertical velocity и публикует grounded state;
|
||||
- player movement, run/jump constants и прежняя geometry не менялись.
|
||||
|
||||
`physicsVersion` остаётся `1`: существующие уровни не авторизуют вертикально
|
||||
пересекающиеся objects, поэтому принятые trace/geometry не меняются; новый контакт
|
||||
активируется только новой stacked geometry Level 32.
|
||||
|
||||
## Device evidence
|
||||
|
||||
- targeted first run: `4/4` methods, `12` routes, `10.003 s`;
|
||||
- targeted rerun после restart-proof: `4/4`, GREEN;
|
||||
- cold unfiltered Android 16: `70/70`, failures/errors/skipped `0/0/0`,
|
||||
instrumentation `611.991 s`, `BUILD SUCCESSFUL in 10m23s`;
|
||||
- app JVM: `122/122`; level-format JVM: `7/7`;
|
||||
- clean `lintDebug`, app APK и AndroidTest APK: GREEN;
|
||||
- validator: `34` levels; matrix `17/52` authored production rows;
|
||||
- APK ZIP: no errors; `34` level JSON packaged;
|
||||
- debug APK SHA-256:
|
||||
`1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3`.
|
||||
|
||||
Следующий bounded slice — Physics 35–38: moving hazard, returning projectile,
|
||||
blast delivery и moving load.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Stage 6 production hazards
|
||||
|
||||
Date: 19 August 2026
|
||||
|
||||
Beads task: `goon-game-hazards-full`
|
||||
|
||||
## Delivered contract
|
||||
|
||||
- JSON hazard definitions now support crusher, fire, projectile and explosion
|
||||
types with data-bound cycle, active window, velocity, visual style and audio cue.
|
||||
- `HazardRuntime` owns deterministic cycle state and moving projectile geometry.
|
||||
- `GameView` collision, drawing, procedural audio and restore consume the same
|
||||
runtime definition; no hazard timing is duplicated in the bot.
|
||||
- Four focused teaching levels extend the catalog from 10 to 14.
|
||||
- The level-select UI pages an arbitrary catalog in groups of ten and exposes all
|
||||
four new levels through real touch controls and RU/EN copy.
|
||||
- Tape/session validation derives the upper level bound from `LevelCatalog`
|
||||
instead of a hard-coded ten-level limit.
|
||||
|
||||
## Teaching routes
|
||||
|
||||
- Level 11: sacrifice to a rhythmic crusher, then cross during a fresh REST window.
|
||||
- Level 12: sacrifice to fire, then take off from the safe staging point and clear it.
|
||||
- Level 13: read and evade two consecutive projectile cycles after one sacrifice.
|
||||
- Level 14: explosion-deliver a body to a pressure plate, cross the next REST
|
||||
window, jump the retained body and pass the opened door.
|
||||
|
||||
Every route uses production input and read-only snapshots. It has no restart,
|
||||
retry, teleport or direct completion mutation and asserts exactly one death and
|
||||
one retained body. Level 14 additionally requires the door-open event.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
RED cycles covered missing schema symbols, missing runtime, each new hazard type,
|
||||
the old hard-coded ten-level tape/session bound, missing level pagination and
|
||||
missing intended-route scenarios. Device REDs then exposed:
|
||||
|
||||
- a crusher crossing begun midway through its rest interval;
|
||||
- a fire jump staged too early and descending into the far edge;
|
||||
- a second projectile cycle not handled by the initial route;
|
||||
- an explosion impulse that overshot its required plate;
|
||||
- an always-active explosion box that could not be cleared with release physics;
|
||||
- the retained level-14 body correctly blocking the living player at the plate.
|
||||
|
||||
Each was resolved at the narrowest responsible layer: state-driven choreography,
|
||||
authored explosion impulse/cycle, and an explicit jump over the retained body.
|
||||
|
||||
## Focused acceptance
|
||||
|
||||
- Validator: `14/14`, format `1`, physics `1`.
|
||||
- Level 11: `3/3`, `46.414 s`.
|
||||
- Level 12: `3/3`, `41.143 s`.
|
||||
- Level 13: `3/3`, `44.630 s`.
|
||||
- Level 14: `3/3`, `52.089 s`.
|
||||
- Combined one-install matrix: four routes, twelve playthroughs plus touch
|
||||
pagination, `5/5` test methods, `221.894 s`.
|
||||
|
||||
## Final gates
|
||||
|
||||
- JVM: `99`, failures `0`, errors `0`, skipped `0`.
|
||||
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN from clean.
|
||||
- final unfiltered Android 16 matrix: `43/43`, failures `0`, `2004.172 s`, on
|
||||
the exact clean-built final app/test APK.
|
||||
- debug APK ZIP integrity: `unzip -t` GREEN and contains all 14 JSON levels.
|
||||
- debug APK SHA-256:
|
||||
`f220ada1da1e32bbca15615487f49a1ac3b61d924af292b648b6693a14d5f212`.
|
||||
|
||||
The dedicated AVD was stopped after the gate.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Stage 6 event-driven PuzzleSystem
|
||||
|
||||
Date: 19 August 2026
|
||||
|
||||
Beads task: `goon-game-puzzle-events`
|
||||
|
||||
## Contract
|
||||
|
||||
`PuzzleSystem` is the deterministic owner of puzzle signal channels. Each frame,
|
||||
`GameView` submits data-bound source facts and consumes published transitions:
|
||||
|
||||
- pressure and conductive-relay sources aggregate by channel;
|
||||
- toggle and timed sources react only to their own rising contact edge;
|
||||
- timed channels expire deterministically and can be rearmed;
|
||||
- `DoorController` has an explicit `CLOSED` or `OPEN` state and ANDs its required
|
||||
channels;
|
||||
- controlled platforms read the same channel state as doors and rendering;
|
||||
- signal and door open/close transitions are immutable frame events;
|
||||
- diagnostics expose update and transition counts without mutating gameplay.
|
||||
|
||||
`GameView` remains the geometry adapter: it detects player/body contact and
|
||||
conductive-body overlap, but it no longer owns toggle/timer maps or calculates a
|
||||
door's required signals. Door audio and camera focus consume `DOOR_OPENED` rather
|
||||
than comparing an ad-hoc previous aggregate.
|
||||
|
||||
The existing JSON `signalId`, `requiredSignals`, `controlId`, duration, and mass
|
||||
bindings remain unchanged. Existing content validation still rejects missing
|
||||
sources before packaging.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Puzzle state has an immutable snapshot containing toggle and timed channels.
|
||||
Reset clears channels, source edges, door states, events, and diagnostics. Restore
|
||||
also seeds held-source edge activity, so recreating an Activity while standing on
|
||||
a toggle or timer does not activate it a second time. Pressure and relay state is
|
||||
recomputed from restored bodies and geometry without user feedback side effects.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
Separate RED cycles covered the missing base contract, pressure aggregation,
|
||||
timed expiry/rearm, relay plus explicit AND-door transitions, immutable
|
||||
reset/save/restore, restored held-source edges, diagnostics, and the missing
|
||||
`GameView` integration hook. Every RED was followed by its focused GREEN before
|
||||
the next behavior was added.
|
||||
|
||||
The focused Android test completes the level-3 production-input route, leaves the
|
||||
saw body on its pressure plate, and verifies an activated signal, a published
|
||||
door-open transition, and one open `DoorController` through read-only diagnostics.
|
||||
|
||||
## Final gates
|
||||
|
||||
- JSON validator: `10/10`, format `1`, physics `1`.
|
||||
- JVM: `89`, failures `0`, errors `0`, skipped `0`.
|
||||
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN.
|
||||
- focused Android 16 pressure/door integration: `1/1` GREEN.
|
||||
- final unfiltered Android 16 matrix: `38/38`, failures `0`, errors `0`,
|
||||
skipped `0`, `433.599 s`, on the exact final APK/test APK.
|
||||
- debug APK ZIP integrity: `unzip -t` GREEN.
|
||||
- debug APK SHA-256:
|
||||
`adeed8b3959e2192e15917136e754e626cf06248bbba0dcd7596d55b2685e011`.
|
||||
|
||||
The dedicated AVD was stopped after the final gate. No movement, collision,
|
||||
physics version, level geometry, or accepted solution signature changed.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Stage 6 weighted objects
|
||||
|
||||
Date: 19 August 2026
|
||||
|
||||
Beads task: `goon-game-weighted-objects`
|
||||
|
||||
## Contract
|
||||
|
||||
`IWeightedObject` is the only pressure-mass contract. It exposes world geometry,
|
||||
integer pressure mass, and whether the object may activate a mechanism. The live
|
||||
player, retained corpse bodies, and authored physical objects implement it;
|
||||
`PressureMassResolver` knows no concrete object types.
|
||||
|
||||
The version-1 level schema now accepts an optional `objects` array with a strict
|
||||
`type`, `box`, and positive `mass`. Supported production types are `BOX`, `ROCK`,
|
||||
`BALL`, `ICE_BLOCK`, and `COUNTERWEIGHT`, matching the Beads scope. Existing JSON
|
||||
without `objects` stays source-compatible. Validation, deterministic SVG/TSV
|
||||
preview, Canvas rendering, collision/push physics, moving-platform support,
|
||||
automation and performance accounting all consume the same definitions.
|
||||
|
||||
Active-session save version 4 stores object type, geometry, velocity, mass,
|
||||
grounding and moving-platform support. V1–v3 decode with an empty runtime object
|
||||
list; levels from those versions had no authored physical objects, so restore
|
||||
uses the level's initial definitions without inventing state.
|
||||
|
||||
## Teaching puzzle
|
||||
|
||||
Level 17, `The Whole Weight`, places Box(1), Rock(2), Ball(1), and IceBlock(2) on
|
||||
a pressure plate. Their total 6 cannot open the mass-9 door. The player must push
|
||||
Counterweight(3) onto the plate, release input to prove object-only retention,
|
||||
then jump across the weighted row and pass the open door.
|
||||
|
||||
## TDD and acceptance evidence
|
||||
|
||||
- RED: missing `IWeightedObject`/resolver and all JSON object symbols (28 compile
|
||||
errors); GREEN: shared pure mass aggregation and five parsed types.
|
||||
- RED: missing runtime/save-v4 symbols (13 compile errors); GREEN: deterministic
|
||||
state, corpse interface parity, v4 round-trip and v1–v3 migrations.
|
||||
- RED: catalog expected 17 but contained 16; GREEN: validator `17/17` and bounded
|
||||
two-page pagination.
|
||||
- RED: Android scenario class absent; GREEN: compiled production-input route.
|
||||
- RED: preview lacked object geometry; GREEN: byte-stable SVG and TSV include
|
||||
object type and mass.
|
||||
- Focused push/door/save integration: `1/1` GREEN in `9.128 s`.
|
||||
- Level 17 intended route: `3/3` GREEN in `31.119 s`, exact zero deaths/bodies.
|
||||
- Focused player/corpse/object pressure and restore matrix: `8/8` GREEN in
|
||||
`315.926 s`.
|
||||
- Clean gate: app JVM `108/108`, content-tool JVM `2/2`, validator `17/17`,
|
||||
lint, app APK and test APK GREEN.
|
||||
- Final exact-candidate unfiltered Android matrix: `48/48` GREEN in
|
||||
`2,223.695 s`, with zero failures and zero skipped tests.
|
||||
- Debug APK ZIP/signature: GREEN; packaged level JSON count: `17`.
|
||||
- Debug APK SHA-256:
|
||||
`141eecd3605d5cc5ab8ac20b52b666650b672c0723e2a3f8ea2187e75adca002`.
|
||||
@@ -33,14 +33,14 @@
|
||||
| 9 | Beta | Feature freeze, широкая QA-матрица и исправления | HITL |
|
||||
| 10 | Release Candidate | Финальный release gate без blocker/critical defects | HITL |
|
||||
|
||||
## Незавершённые сквозные gates
|
||||
## Сквозные gates
|
||||
|
||||
- `goon-game-regression-10` имеет `stage-1`, но primary Stage 5: закрыт после
|
||||
трёх детерминированных полных replay-проходов 10/10 с under-five-minute guards.
|
||||
- `goon-game-level-balance` имеет `stage-2`, но primary Stage 5: инженерные
|
||||
исправления сделаны, а внешний comprehension/playtest gate остаётся открытым.
|
||||
- `goon-game-human-playtest` относится к Stage 5 и не может быть закрыт ботами.
|
||||
- `goon-game-content-worlds` имеет primary Stage 6 и начинается после принятия
|
||||
- `goon-game-level-balance` и `goon-game-human-playtest` закрыты после прямого
|
||||
подтверждения владельцем завершения внешнего тестирования; raw session forms
|
||||
не приложены и не реконструируются.
|
||||
- `goon-game-content-worlds` имеет primary Stage 6 и разблокирован принятым
|
||||
`goon-game-milestone-vslice` Stage 5.
|
||||
|
||||
## Проверка
|
||||
@@ -64,8 +64,8 @@ Snapshot на 19 августа 2026 года:
|
||||
| 2 | 5 | 0 |
|
||||
| 3 | 5 | 0 |
|
||||
| 4 | 4 | 0 |
|
||||
| 5 | 15 | 3 |
|
||||
| 6 | 18 | 18 |
|
||||
| 5 | 15 | 0 |
|
||||
| 6 | 18 | 6 |
|
||||
| 7 | 9 | 9 |
|
||||
| 8 | 1 | 1 |
|
||||
| 9 | 1 | 1 |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user