Compare commits
7
Commits
master
...
3de8d45039
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de8d45039 | ||
|
|
259fcd316b | ||
|
|
19bb64edfb | ||
|
|
dc2ebcbf8c | ||
|
|
cc7f487cf7 | ||
|
|
cc3b5902cc | ||
|
|
62ee6877f9 |
+12
-12
@@ -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-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.","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-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
|
||||
@@ -105,6 +116,25 @@ 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`.
|
||||
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 latest exact gate is `116 app JVM + 2
|
||||
content-tool JVM / 52 Android` on the eighteen-level candidate (`OK (52 tests)`,
|
||||
551.773 seconds). The verified debug APK SHA-256 is
|
||||
`0b70a0a1a508425c311d7de473318e3bb83407f5f27e9dd4a8e1876b2eb25344`.
|
||||
|
||||
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with:
|
||||
|
||||
```sh
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
@@ -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,67 @@
|
||||
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(2, 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"));
|
||||
assertFalse(visible.contains("The Volunteer"));
|
||||
assertTrue(visible.contains("Page 2 of 2"));
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
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.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("Production-hazard bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Intended routes for the first four data-driven production hazards. */
|
||||
final class ProductionHazardScenarios {
|
||||
private ProductionHazardScenarios() {}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelEleven() {
|
||||
return LevelPlaytestBot.Scenario.builder(11, "Level 11 - crusher rhythm")
|
||||
.step("Sacrifice one volunteer to the press", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the retained body", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Observe the press close for a fresh cycle", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::firstHazardActive)
|
||||
.step("Observe the press resting", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.firstHazardActive())
|
||||
.step("Jump through the resting press", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 900f))
|
||||
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelTwelve() {
|
||||
return LevelPlaytestBot.Scenario.builder(12, "Level 12 - blue fire")
|
||||
.step("Sacrifice one volunteer to the flame", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the blue flame", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(535f)))
|
||||
.step("Take off before the flame", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 700f))
|
||||
.step("Clear the flame", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 850f))
|
||||
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelThirteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(13, "Level 13 - clockwork projectile")
|
||||
.step("Read the bolt by sacrificing once", 5_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the firing line", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(600f)))
|
||||
.step("Wait for the jump window", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.firstHazardX() >= 850f
|
||||
&& snapshot.firstHazardX() <= 930f))
|
||||
.step("Jump through the bolt line", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Clear the firing line", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_050f))
|
||||
.step("Wait for the returning bolt", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.firstHazardX() >= 1_350f
|
||||
&& snapshot.firstHazardX() <= 1_450f))
|
||||
.step("Jump through the returning bolt", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Clear the second firing line", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_600f))
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelFourteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(14, "Level 14 - explosion delivery")
|
||||
.step("Give one volunteer to the blast", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
|
||||
.step("Observe the delivered body open the gate", 5_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::allDoorsOpen)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the steam charge", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(390f)))
|
||||
.step("Observe the charge rebuild", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::firstHazardActive)
|
||||
.step("Observe the safe blast rest", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.firstHazardActive())
|
||||
.step("Take off before the blast", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Clear the blast", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 820f))
|
||||
.step("Approach the delivered body", 1_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 880f)
|
||||
.step("Jump over the delivered body", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_200f))
|
||||
.step("Pass the opened gate", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_550f)
|
||||
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class PuzzleSystemIntegrationTest {
|
||||
@Test
|
||||
public void pressureBodyPublishesSignalAndDoorEventsThroughProductionInput() {
|
||||
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
new AndroidDriver(instrumentation, view[0]),
|
||||
EarlyLevelScenarios.levelThree(), 25_000L);
|
||||
assertTrue(result.diagnostic(), result.completed());
|
||||
|
||||
PuzzleSystem.Diagnostics[] diagnostics = new PuzzleSystem.Diagnostics[1];
|
||||
instrumentation.runOnMainSync(
|
||||
() -> diagnostics[0] = view[0].puzzleDiagnosticsForAutomation());
|
||||
assertTrue(diagnostics[0].updates() > 0L);
|
||||
assertTrue(diagnostics[0].signalActivatedTransitions() >= 1L);
|
||||
assertTrue(diagnostics[0].doorOpenedTransitions() >= 1L);
|
||||
assertEquals(1, diagnostics[0].openDoors());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
AndroidDriver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view.setAutomationInput(moveLeft, moveRight, jump));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] result = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> result[0] = view.automationSnapshot());
|
||||
return result[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Puzzle integration bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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,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;
|
||||
|
||||
@@ -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(18, 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,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,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; }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,14 @@ baseline-задачи также имели явного владельца по
|
||||
переход дальше; границы доступного 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, несколько итераций
|
||||
|
||||
Задачи:
|
||||
@@ -178,6 +186,29 @@ baseline-задачи также имели явного владельца по
|
||||
- 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`.
|
||||
|
||||
### Этап 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,186 @@ 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`.
|
||||
|
||||
## Проверки, следующие из графов
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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,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,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`.
|
||||
@@ -65,7 +65,7 @@ Snapshot на 19 августа 2026 года:
|
||||
| 3 | 5 | 0 |
|
||||
| 4 | 4 | 0 |
|
||||
| 5 | 15 | 0 |
|
||||
| 6 | 18 | 18 |
|
||||
| 6 | 18 | 6 |
|
||||
| 7 | 9 | 9 |
|
||||
| 8 | 1 | 1 |
|
||||
| 9 | 1 | 1 |
|
||||
|
||||
@@ -24,6 +24,7 @@ public final class LevelCatalogLoader {
|
||||
|
||||
List<LevelDefinition> result = new ArrayList<>();
|
||||
Set<Integer> numbers = new HashSet<>();
|
||||
Set<String> levelIds = new HashSet<>();
|
||||
String directory = directory(index);
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
String path = directory + entries.get(i);
|
||||
@@ -37,6 +38,10 @@ public final class LevelCatalogLoader {
|
||||
throw new IllegalArgumentException(index + ".levels[" + i
|
||||
+ "] duplicates level number " + level.number);
|
||||
}
|
||||
if (!levelIds.add(level.levelId)) {
|
||||
throw new IllegalArgumentException(index + ".levels[" + i
|
||||
+ "] duplicates level id '" + level.levelId + "'");
|
||||
}
|
||||
int expected = i + 1;
|
||||
if (level.number != expected) {
|
||||
throw new IllegalArgumentException(index + ".levels[" + i + "] expected level number "
|
||||
|
||||
@@ -72,6 +72,10 @@ public final class LevelContentTool {
|
||||
for (int i = 0; i < level.platforms.size(); i++) {
|
||||
rect(value, "platform", i, level.platforms.get(i).box, "#64748b");
|
||||
}
|
||||
for (int i = 0; i < level.objects.size(); i++) {
|
||||
LevelDefinition.PhysicalObject item = level.objects.get(i);
|
||||
rect(value, "object-" + item.type, i, item.box, "#f59e0b");
|
||||
}
|
||||
for (int i = 0; i < level.hazards.size(); i++) {
|
||||
rect(value, "hazard-" + level.hazards.get(i).type, i,
|
||||
level.hazards.get(i).box, "#ef4444");
|
||||
@@ -85,6 +89,9 @@ public final class LevelContentTool {
|
||||
}
|
||||
rect(value, "spawn", 0, new LevelDefinition.Box(level.spawnX, level.spawnY, 62f, 100f), "#22c55e");
|
||||
rect(value, "goal", 0, level.goal, "#38bdf8");
|
||||
if (level.collectible != null) {
|
||||
rect(value, "collectible", 0, level.collectible, "#facc15");
|
||||
}
|
||||
value.append("</svg>\n");
|
||||
return value.toString();
|
||||
}
|
||||
@@ -103,11 +110,20 @@ public final class LevelContentTool {
|
||||
row(value, level.number, "spawn", 0, "PLAYER", new LevelDefinition.Box(
|
||||
level.spawnX, level.spawnY, 62f, 100f), 0f, 0f, "");
|
||||
row(value, level.number, "goal", 0, "GOAL", level.goal, 0f, 0f, "");
|
||||
if (level.collectible != null) {
|
||||
row(value, level.number, "collectible", 0, "CACHE", level.collectible,
|
||||
0f, 0f, level.levelId);
|
||||
}
|
||||
for (int i = 0; i < level.platforms.size(); i++) {
|
||||
LevelDefinition.Platform item = level.platforms.get(i);
|
||||
row(value, level.number, "platform", i, item.isMoving() ? "MOVING" : "STATIC",
|
||||
item.box, item.travelX, item.travelY, item.controlId);
|
||||
}
|
||||
for (int i = 0; i < level.objects.size(); i++) {
|
||||
LevelDefinition.PhysicalObject item = level.objects.get(i);
|
||||
row(value, level.number, "object", i, item.type.name(), item.box,
|
||||
0f, 0f, "mass=" + item.mass);
|
||||
}
|
||||
for (int i = 0; i < level.hazards.size(); i++) {
|
||||
LevelDefinition.Hazard item = level.hazards.get(i);
|
||||
row(value, level.number, "hazard", i, item.type.name(), item.box,
|
||||
|
||||
@@ -6,8 +6,11 @@ import java.util.List;
|
||||
|
||||
/** Immutable, Android-independent level data. */
|
||||
public final class LevelDefinition {
|
||||
public enum HazardType { SPIKES, SAW, FREEZER, ELECTRIC, PIT }
|
||||
public enum HazardType {
|
||||
SPIKES, SAW, FREEZER, ELECTRIC, PIT, CRUSHER, FIRE, PROJECTILE, EXPLOSION
|
||||
}
|
||||
public enum SwitchType { PRESSURE, TOGGLE, TIMED }
|
||||
public enum PhysicalObjectType { BOX, ROCK, BALL, ICE_BLOCK, COUNTERWEIGHT }
|
||||
|
||||
public static final class Box {
|
||||
public final float x;
|
||||
@@ -54,17 +57,36 @@ public final class LevelDefinition {
|
||||
public final String relayId;
|
||||
public final float impulseX;
|
||||
public final float impulseY;
|
||||
public final float cycleSeconds;
|
||||
public final float activeSeconds;
|
||||
public final float speedX;
|
||||
public final float speedY;
|
||||
public final String visualStyle;
|
||||
public final String audioCue;
|
||||
|
||||
public Hazard(HazardType type, Box box) {
|
||||
this(type, box, null, 0f, 0f);
|
||||
}
|
||||
|
||||
public Hazard(HazardType type, Box box, String relayId, float impulseX, float impulseY) {
|
||||
this(type, box, relayId, impulseX, impulseY,
|
||||
0f, 0f, 0f, 0f, null, null);
|
||||
}
|
||||
|
||||
public Hazard(HazardType type, Box box, String relayId, float impulseX, float impulseY,
|
||||
float cycleSeconds, float activeSeconds, float speedX, float speedY,
|
||||
String visualStyle, String audioCue) {
|
||||
this.type = type;
|
||||
this.box = box;
|
||||
this.relayId = relayId;
|
||||
this.impulseX = impulseX;
|
||||
this.impulseY = impulseY;
|
||||
this.cycleSeconds = cycleSeconds;
|
||||
this.activeSeconds = activeSeconds;
|
||||
this.speedX = speedX;
|
||||
this.speedY = speedY;
|
||||
this.visualStyle = visualStyle;
|
||||
this.audioCue = audioCue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +116,23 @@ public final class LevelDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class PhysicalObject {
|
||||
public final PhysicalObjectType type;
|
||||
public final Box box;
|
||||
public final int mass;
|
||||
|
||||
public PhysicalObject(PhysicalObjectType type, Box box, int mass) {
|
||||
this.type = type;
|
||||
this.box = box;
|
||||
this.mass = mass;
|
||||
}
|
||||
}
|
||||
|
||||
public final int number;
|
||||
public final int formatVersion;
|
||||
public final int physicsVersion;
|
||||
public final String levelId;
|
||||
public final String worldId;
|
||||
public final String title;
|
||||
public final String hint;
|
||||
public final String titleRu;
|
||||
@@ -108,6 +144,8 @@ public final class LevelDefinition {
|
||||
public final Box goal;
|
||||
public final int targetDeaths;
|
||||
public final int targetSeconds;
|
||||
public final Box collectible;
|
||||
public final List<PhysicalObject> objects;
|
||||
public final List<Platform> platforms;
|
||||
public final List<Hazard> hazards;
|
||||
public final List<Switch> switches;
|
||||
@@ -130,7 +168,7 @@ public final class LevelDefinition {
|
||||
) {
|
||||
this(1, 1, number, title, hint, title, hint, worldWidth, 1080f,
|
||||
spawnX, spawnY, goal, targetDeaths, targetSeconds,
|
||||
platforms, hazards, switches, doors);
|
||||
Collections.emptyList(), platforms, hazards, switches, doors);
|
||||
}
|
||||
|
||||
public LevelDefinition(
|
||||
@@ -152,9 +190,67 @@ public final class LevelDefinition {
|
||||
List<Hazard> hazards,
|
||||
List<Switch> switches,
|
||||
List<Door> doors
|
||||
) {
|
||||
this(formatVersion, physicsVersion, number, title, hint, titleRu, hintRu,
|
||||
worldWidth, worldHeight, spawnX, spawnY, goal, targetDeaths,
|
||||
targetSeconds, Collections.emptyList(), platforms, hazards, switches, doors);
|
||||
}
|
||||
|
||||
public LevelDefinition(
|
||||
int formatVersion,
|
||||
int physicsVersion,
|
||||
int number,
|
||||
String title,
|
||||
String hint,
|
||||
String titleRu,
|
||||
String hintRu,
|
||||
float worldWidth,
|
||||
float worldHeight,
|
||||
float spawnX,
|
||||
float spawnY,
|
||||
Box goal,
|
||||
int targetDeaths,
|
||||
int targetSeconds,
|
||||
List<PhysicalObject> objects,
|
||||
List<Platform> platforms,
|
||||
List<Hazard> hazards,
|
||||
List<Switch> switches,
|
||||
List<Door> doors
|
||||
) {
|
||||
this(formatVersion, physicsVersion, defaultLevelId(number), "legacy",
|
||||
number, title, hint, titleRu, hintRu, worldWidth, worldHeight,
|
||||
spawnX, spawnY, goal, targetDeaths, targetSeconds, null, objects,
|
||||
platforms, hazards, switches, doors);
|
||||
}
|
||||
|
||||
public LevelDefinition(
|
||||
int formatVersion,
|
||||
int physicsVersion,
|
||||
String levelId,
|
||||
String worldId,
|
||||
int number,
|
||||
String title,
|
||||
String hint,
|
||||
String titleRu,
|
||||
String hintRu,
|
||||
float worldWidth,
|
||||
float worldHeight,
|
||||
float spawnX,
|
||||
float spawnY,
|
||||
Box goal,
|
||||
int targetDeaths,
|
||||
int targetSeconds,
|
||||
Box collectible,
|
||||
List<PhysicalObject> objects,
|
||||
List<Platform> platforms,
|
||||
List<Hazard> hazards,
|
||||
List<Switch> switches,
|
||||
List<Door> doors
|
||||
) {
|
||||
this.formatVersion = formatVersion;
|
||||
this.physicsVersion = physicsVersion;
|
||||
this.levelId = levelId;
|
||||
this.worldId = worldId;
|
||||
this.number = number;
|
||||
this.title = title;
|
||||
this.hint = hint;
|
||||
@@ -167,12 +263,18 @@ public final class LevelDefinition {
|
||||
this.goal = goal;
|
||||
this.targetDeaths = targetDeaths;
|
||||
this.targetSeconds = targetSeconds;
|
||||
this.collectible = collectible;
|
||||
this.objects = Collections.unmodifiableList(objects);
|
||||
this.platforms = Collections.unmodifiableList(platforms);
|
||||
this.hazards = Collections.unmodifiableList(hazards);
|
||||
this.switches = Collections.unmodifiableList(switches);
|
||||
this.doors = Collections.unmodifiableList(doors);
|
||||
}
|
||||
|
||||
public static String defaultLevelId(int number) {
|
||||
return "legacy.level-" + (number < 10 ? "0" : "") + number;
|
||||
}
|
||||
|
||||
public boolean containsMechanic(HazardType type) {
|
||||
for (Hazard hazard : hazards) {
|
||||
if (hazard.type == type) return true;
|
||||
|
||||
@@ -37,14 +37,24 @@ public final class LevelJsonLoader {
|
||||
optionalText(value, "controlId", path)));
|
||||
}
|
||||
|
||||
List<LevelDefinition.PhysicalObject> objects = physicalObjects(root, source);
|
||||
List<LevelDefinition.Hazard> hazards = hazards(root, source);
|
||||
List<LevelDefinition.Switch> switches = switches(root, source);
|
||||
List<LevelDefinition.Door> doors = doors(root, source);
|
||||
int number = integer(root, "number", source);
|
||||
String levelId = root.containsKey("id")
|
||||
? text(root, "id", source) : LevelDefinition.defaultLevelId(number);
|
||||
String worldId = root.containsKey("worldId")
|
||||
? text(root, "worldId", source) : "legacy";
|
||||
LevelDefinition.Box collectible = root.containsKey("collectible")
|
||||
? box(root.get("collectible"), source + ".collectible") : null;
|
||||
|
||||
return LevelValidator.validate(new LevelDefinition(
|
||||
integer(root, "formatVersion", source),
|
||||
integer(root, "physicsVersion", source),
|
||||
integer(root, "number", source),
|
||||
levelId,
|
||||
worldId,
|
||||
number,
|
||||
text(english, "title", source + ".copy.en"),
|
||||
text(english, "hint", source + ".copy.en"),
|
||||
text(russian, "title", source + ".copy.ru"),
|
||||
@@ -56,12 +66,30 @@ public final class LevelJsonLoader {
|
||||
box(required(root, "goal", source), source + ".goal"),
|
||||
integer(targets, "deaths", source + ".targets"),
|
||||
integer(targets, "seconds", source + ".targets"),
|
||||
collectible,
|
||||
objects,
|
||||
platforms,
|
||||
hazards,
|
||||
switches,
|
||||
doors), source);
|
||||
}
|
||||
|
||||
private static List<LevelDefinition.PhysicalObject> physicalObjects(
|
||||
Map<String, Object> root, String source) {
|
||||
if (!root.containsKey("objects")) return Collections.emptyList();
|
||||
List<Object> values = array(root.get("objects"), source + ".objects");
|
||||
List<LevelDefinition.PhysicalObject> result = new ArrayList<>();
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
String path = source + ".objects[" + i + "]";
|
||||
Map<String, Object> value = object(values.get(i), path);
|
||||
result.add(new LevelDefinition.PhysicalObject(
|
||||
physicalObjectType(text(value, "type", path), path + ".type"),
|
||||
box(required(value, "box", path), path + ".box"),
|
||||
integer(value, "mass", path)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<String> loadIndex(Reader reader, String sourceName) {
|
||||
String source = sourceName == null ? "<levels-index>" : sourceName;
|
||||
Map<String, Object> root = object(new JsonValueParser(reader, source).parse(), source);
|
||||
@@ -106,7 +134,13 @@ public final class LevelJsonLoader {
|
||||
box(required(value, "box", path), path + ".box"),
|
||||
optionalText(value, "relayId", path),
|
||||
optionalDecimal(value, "impulseX", path, 0f),
|
||||
optionalDecimal(value, "impulseY", path, 0f)));
|
||||
optionalDecimal(value, "impulseY", path, 0f),
|
||||
optionalDecimal(value, "cycleSeconds", path, 0f),
|
||||
optionalDecimal(value, "activeSeconds", path, 0f),
|
||||
optionalDecimal(value, "speedX", path, 0f),
|
||||
optionalDecimal(value, "speedY", path, 0f),
|
||||
optionalText(value, "visualStyle", path),
|
||||
optionalText(value, "audioCue", path)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -165,6 +199,15 @@ public final class LevelJsonLoader {
|
||||
}
|
||||
}
|
||||
|
||||
private static LevelDefinition.PhysicalObjectType physicalObjectType(
|
||||
String value, String path) {
|
||||
try {
|
||||
return LevelDefinition.PhysicalObjectType.valueOf(value);
|
||||
} catch (IllegalArgumentException error) {
|
||||
throw invalid(path, "has unknown physical object type '" + value + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireEmptyArray(Map<String, Object> parent, String key, String path) {
|
||||
List<Object> values = array(required(parent, key, path), path + "." + key);
|
||||
if (!values.isEmpty()) {
|
||||
|
||||
@@ -25,6 +25,13 @@ public final class LevelValidator {
|
||||
if (level.platforms.isEmpty()) {
|
||||
throw invalid(source + ".platforms", "must contain at least one platform");
|
||||
}
|
||||
for (int i = 0; i < level.objects.size(); i++) {
|
||||
LevelDefinition.PhysicalObject object = level.objects.get(i);
|
||||
String path = source + ".objects[" + i + "]";
|
||||
if (object.type == null) throw invalid(path + ".type", "must be known");
|
||||
box(object.box, level, path + ".box");
|
||||
if (object.mass <= 0) throw invalid(path + ".mass", "must be positive");
|
||||
}
|
||||
for (int i = 0; i < level.platforms.size(); i++) {
|
||||
LevelDefinition.Platform platform = level.platforms.get(i);
|
||||
String path = source + ".platforms[" + i + "]";
|
||||
@@ -40,7 +47,47 @@ public final class LevelValidator {
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < level.hazards.size(); i++) {
|
||||
box(level.hazards.get(i).box, level, source + ".hazards[" + i + "].box");
|
||||
LevelDefinition.Hazard hazard = level.hazards.get(i);
|
||||
String path = source + ".hazards[" + i + "]";
|
||||
box(hazard.box, level, path + ".box");
|
||||
if (hazard.type == LevelDefinition.HazardType.CRUSHER) {
|
||||
positive(hazard.cycleSeconds, path + ".cycleSeconds");
|
||||
positive(hazard.activeSeconds, path + ".activeSeconds");
|
||||
if (hazard.activeSeconds > hazard.cycleSeconds) {
|
||||
throw invalid(path + ".activeSeconds", "must not exceed cycleSeconds");
|
||||
}
|
||||
requiredText(hazard.visualStyle, path + ".visualStyle");
|
||||
requiredText(hazard.audioCue, path + ".audioCue");
|
||||
}
|
||||
if (hazard.type == LevelDefinition.HazardType.FIRE) {
|
||||
requiredText(hazard.visualStyle, path + ".visualStyle");
|
||||
requiredText(hazard.audioCue, path + ".audioCue");
|
||||
}
|
||||
if (hazard.type == LevelDefinition.HazardType.PROJECTILE) {
|
||||
positive(hazard.cycleSeconds, path + ".cycleSeconds");
|
||||
if (hazard.speedX == 0f && hazard.speedY == 0f) {
|
||||
throw invalid(path + ".speedX", "or speedY must be non-zero");
|
||||
}
|
||||
LevelDefinition.Box destination = new LevelDefinition.Box(
|
||||
hazard.box.x + hazard.speedX * hazard.cycleSeconds,
|
||||
hazard.box.y + hazard.speedY * hazard.cycleSeconds,
|
||||
hazard.box.width, hazard.box.height);
|
||||
box(destination, level, path + ".destinationBox");
|
||||
requiredText(hazard.visualStyle, path + ".visualStyle");
|
||||
requiredText(hazard.audioCue, path + ".audioCue");
|
||||
}
|
||||
if (hazard.type == LevelDefinition.HazardType.EXPLOSION) {
|
||||
positive(hazard.cycleSeconds, path + ".cycleSeconds");
|
||||
positive(hazard.activeSeconds, path + ".activeSeconds");
|
||||
if (hazard.activeSeconds > hazard.cycleSeconds) {
|
||||
throw invalid(path + ".activeSeconds", "must not exceed cycleSeconds");
|
||||
}
|
||||
if (hazard.impulseX == 0f && hazard.impulseY == 0f) {
|
||||
throw invalid(path + ".impulseX", "or impulseY must be non-zero");
|
||||
}
|
||||
requiredText(hazard.visualStyle, path + ".visualStyle");
|
||||
requiredText(hazard.audioCue, path + ".audioCue");
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < level.switches.size(); i++) {
|
||||
LevelDefinition.Switch item = level.switches.get(i);
|
||||
@@ -62,6 +109,13 @@ public final class LevelValidator {
|
||||
for (int i = 0; i < level.doors.size(); i++) {
|
||||
box(level.doors.get(i).box, level, source + ".doors[" + i + "].box");
|
||||
}
|
||||
if (level.collectible != null) box(level.collectible, level, source + ".collectible");
|
||||
if (level.levelId == null || !level.levelId.matches("[a-z0-9][a-z0-9._-]{2,63}")) {
|
||||
throw invalid(source + ".id", "must be a stable lowercase identifier");
|
||||
}
|
||||
if (level.worldId == null || !level.worldId.matches("[a-z0-9][a-z0-9._-]{2,31}")) {
|
||||
throw invalid(source + ".worldId", "must be a stable lowercase identifier");
|
||||
}
|
||||
if (level.targetDeaths < 0) throw invalid(source + ".targets.deaths", "must not be negative");
|
||||
if (level.targetSeconds <= 0) throw invalid(source + ".targets.seconds", "must be positive");
|
||||
Set<String> signalSources = new HashSet<>();
|
||||
@@ -112,6 +166,10 @@ public final class LevelValidator {
|
||||
if (!Float.isFinite(value) || value <= 0f) throw invalid(path, "must be positive and finite");
|
||||
}
|
||||
|
||||
private static void requiredText(String value, String path) {
|
||||
if (value == null || value.trim().isEmpty()) throw invalid(path, "must not be blank");
|
||||
}
|
||||
|
||||
private static IllegalArgumentException invalid(String path, String message) {
|
||||
return new IllegalArgumentException(path + " " + message);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json"]}
|
||||
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json","level-17.json","level-18.json"]}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":11,
|
||||
"copy":{"en":{"title":"The Brass Breather","hint":"The press has a rhythm. Leave one volunteer, then cross while its jaws rest."},"ru":{"title":"Латунный пресс","hint":"У пресса есть ритм. Оставьте одного добровольца и пройдите, пока створки отдыхают."}},
|
||||
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2180,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":35},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
|
||||
"hazards":[{"type":"CRUSHER","box":{"x":700,"y":690,"width":150,"height":190},
|
||||
"impulseX":80,"impulseY":120,"cycleSeconds":4,"activeSeconds":2.5,
|
||||
"speedX":0,"speedY":0,"visualStyle":"brass-press","audioCue":"crusher-impact"}],
|
||||
"switches":[],"doors":[]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":12,
|
||||
"copy":{"en":{"title":"Blue Funeral","hint":"Flame is final for the living. Sacrifice once, then jump the blue fire."},"ru":{"title":"Синее пламя","hint":"Огонь смертелен для живых. Пожертвуйте одним и перепрыгните синее пламя."}},
|
||||
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2180,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":35},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
|
||||
"hazards":[{"type":"FIRE","box":{"x":700,"y":790,"width":120,"height":90},
|
||||
"impulseX":160,"impulseY":-220,"cycleSeconds":0,"activeSeconds":0,
|
||||
"speedX":0,"speedY":0,"visualStyle":"blue-flame","audioCue":"fire-burst"}],
|
||||
"switches":[],"doors":[]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":13,
|
||||
"copy":{"en":{"title":"Crossfire Clock","hint":"The clockwork bolt always returns. Read its line, then jump through the gap."},"ru":{"title":"Часовой обстрел","hint":"Заводной снаряд всегда возвращается. Прочитайте траекторию и прыгните в окно."}},
|
||||
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2180,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":40},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
|
||||
"hazards":[{"type":"PROJECTILE","box":{"x":1700,"y":790,"width":48,"height":48},
|
||||
"impulseX":420,"impulseY":-120,"cycleSeconds":3,"activeSeconds":0,
|
||||
"speedX":-400,"speedY":0,"visualStyle":"clockwork-bolt","audioCue":"projectile-hit"}],
|
||||
"switches":[],"doors":[]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":14,
|
||||
"copy":{"en":{"title":"Steam Charge","hint":"The blast throws what it takes. Deliver a body to the plate and use the open gate."},"ru":{"title":"Паровой заряд","hint":"Взрыв отбрасывает жертву. Доставьте тело на плиту и пройдите в открытые ворота."}},
|
||||
"world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2260,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":45},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2500,"height":200}}],
|
||||
"hazards":[{"type":"EXPLOSION","box":{"x":620,"y":740,"width":140,"height":140},
|
||||
"impulseX":550,"impulseY":-500,"cycleSeconds":4,"activeSeconds":2,
|
||||
"speedX":0,"speedY":0,"visualStyle":"steam-blast","audioCue":"explosion-blast"}],
|
||||
"switches":[{"type":"PRESSURE","box":{"x":980,"y":860,"width":180,"height":20},
|
||||
"signalId":"blast-gate","durationSeconds":0,"requiredMass":1}],
|
||||
"doors":[{"box":{"x":1400,"y":560,"width":82,"height":320},"requiredSignals":["blast-gate"]}]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":15,
|
||||
"copy":{"en":{"title":"Pressed Into Service","hint":"The press leaves a low step. Use its thin profile to reach the ledge."},"ru":{"title":"Служба под прессом","hint":"Пресс оставляет низкую ступень. Используйте её профиль, чтобы достичь уступа."}},
|
||||
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2180,"y":610,"width":86,"height":110},"targets":{"deaths":1,"seconds":40},
|
||||
"platforms":[
|
||||
{"box":{"x":0,"y":880,"width":2400,"height":200}},
|
||||
{"box":{"x":850,"y":720,"width":1550,"height":160}}
|
||||
],
|
||||
"hazards":[{"type":"CRUSHER","box":{"x":700,"y":690,"width":140,"height":190},
|
||||
"impulseX":0,"impulseY":0,"cycleSeconds":4,"activeSeconds":2.5,
|
||||
"speedX":0,"speedY":0,"visualStyle":"brass-press","audioCue":"crusher-impact"}],
|
||||
"switches":[],"doors":[]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":16,
|
||||
"copy":{"en":{"title":"Ashen Counterweight","hint":"Ash remains solid but cannot wake the bridge. Use that difference."},"ru":{"title":"Пепельный противовес","hint":"Пепел остаётся твёрдым, но не запускает мост. Используйте это различие."}},
|
||||
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2200,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":45},
|
||||
"platforms":[
|
||||
{"box":{"x":0,"y":880,"width":1000,"height":200}},
|
||||
{"box":{"x":1000,"y":840,"width":400,"height":40},"travelX":0,"travelY":-300,"periodSeconds":5,"controlId":"burn-weight"},
|
||||
{"box":{"x":1400,"y":880,"width":1000,"height":200}}
|
||||
],
|
||||
"hazards":[
|
||||
{"type":"FIRE","box":{"x":700,"y":790,"width":120,"height":90},
|
||||
"impulseX":0,"impulseY":0,"cycleSeconds":0,"activeSeconds":0,
|
||||
"speedX":0,"speedY":0,"visualStyle":"blue-flame","audioCue":"fire-burst"},
|
||||
{"type":"PIT","box":{"x":1000,"y":880,"width":400,"height":200},
|
||||
"impulseX":0,"impulseY":0,"cycleSeconds":0,"activeSeconds":0,
|
||||
"speedX":0,"speedY":0,"visualStyle":"","audioCue":""}
|
||||
],
|
||||
"switches":[{"type":"PRESSURE","box":{"x":610,"y":860,"width":180,"height":20},
|
||||
"signalId":"burn-weight","durationSeconds":0,"requiredMass":1}],
|
||||
"doors":[]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":17,
|
||||
"copy":{"en":{"title":"The Whole Weight","hint":"Push the counterweight onto the plate. Every object adds its marked weight."},"ru":{"title":"Полный вес","hint":"Толкните противовес на плиту. Каждый объект добавляет указанный вес."}},
|
||||
"world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2300,"y":770,"width":86,"height":110},"targets":{"deaths":0,"seconds":45},
|
||||
"objects":[
|
||||
{"type":"COUNTERWEIGHT","box":{"x":650,"y":780,"width":90,"height":100},"mass":3},
|
||||
{"type":"BOX","box":{"x":1250,"y":800,"width":80,"height":80},"mass":1},
|
||||
{"type":"ROCK","box":{"x":1360,"y":820,"width":60,"height":60},"mass":2},
|
||||
{"type":"BALL","box":{"x":1450,"y":820,"width":60,"height":60},"mass":1},
|
||||
{"type":"ICE_BLOCK","box":{"x":1530,"y":800,"width":80,"height":80},"mass":2}
|
||||
],
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2500,"height":200}}],
|
||||
"hazards":[],
|
||||
"switches":[{"type":"PRESSURE","box":{"x":1000,"y":860,"width":650,"height":20},"signalId":"weight-gate","durationSeconds":0,"requiredMass":9}],
|
||||
"doors":[{"box":{"x":1750,"y":560,"width":82,"height":320},"requiredSignals":["weight-gate"]}]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":18,
|
||||
"id":"foundations.threefold-proof","worldId":"foundations",
|
||||
"copy":{"en":{"title":"Threefold Proof","hint":"Finish efficiently and jump through the optional cache for all three stars."},"ru":{"title":"Тройное доказательство","hint":"Пройдите эффективно и прыгните через тайник, чтобы получить все три звезды."}},
|
||||
"world":{"width":2200,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2000,"y":770,"width":86,"height":110},
|
||||
"collectible":{"x":850,"y":650,"width":58,"height":58},
|
||||
"targets":{"deaths":0,"seconds":30},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2200,"height":200}}],
|
||||
"hazards":[],"switches":[],"doors":[]
|
||||
}
|
||||
@@ -60,6 +60,12 @@ public final class LevelContentToolTest {
|
||||
assertTrue(svg, svg.contains("viewBox=\"0 0 1920 1080\""));
|
||||
assertTrue(svg, svg.contains("data-kind=\"spawn\""));
|
||||
assertTrue(svg, svg.contains("data-kind=\"goal\""));
|
||||
assertTrue(svg, svg.contains("data-kind=\"collectible\""));
|
||||
assertTrue(svg, svg.contains("data-kind=\"object-COUNTERWEIGHT\""));
|
||||
String coordinates = Files.readString(first.resolve("catalog.tsv"));
|
||||
assertTrue(coordinates, coordinates.contains("1\tobject\t0\tCOUNTERWEIGHT"));
|
||||
assertTrue(coordinates, coordinates.contains("mass=3"));
|
||||
assertTrue(coordinates, coordinates.contains("1\tcollectible\t0\tCACHE"));
|
||||
}
|
||||
|
||||
private static void writeFixture(Path levels) throws Exception {
|
||||
@@ -75,7 +81,11 @@ public final class LevelContentToolTest {
|
||||
+ "\"world\":{\"width\":1920,\"height\":1080},"
|
||||
+ "\"spawn\":{\"x\":130,\"y\":760},"
|
||||
+ "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110},"
|
||||
+ "\"collectible\":{\"x\":850,\"y\":650,\"width\":58,\"height\":58},"
|
||||
+ "\"targets\":{\"deaths\":0,\"seconds\":30},"
|
||||
+ "\"objects\":[{\"type\":\"COUNTERWEIGHT\","
|
||||
+ "\"box\":{\"x\":700,\"y\":780,\"width\":80,\"height\":100},"
|
||||
+ "\"mass\":3}],"
|
||||
+ "\"platforms\":[{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}],"
|
||||
+ "\"hazards\":[],\"switches\":[],\"doors\":[]}";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user