Добавить испытания и прогрессию по LevelID

This commit is contained in:
2026-08-19 09:25:32 +03:00
parent 259fcd316b
commit 3de8d45039
37 changed files with 968 additions and 63 deletions
+2 -2
View File
@@ -56,7 +56,7 @@
{"_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-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-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-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-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-e06","title":"EPIC-06 Camera","description":"Следящая камера, look-ahead, damping, bounds и focus targets. Раздел 18.","acceptance_criteria":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","notes":"Stage 5 camera acceptance complete: bounded follow/focus/shake, level bounds and reduced motion; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:03Z","closed_at":"2026-08-18T23:03:03Z","close_reason":"Camera foundation and production polish acceptance are complete.","metadata":{"stages":"0,5","primary_stage":5},"labels":["camera","epic","primary-stage-5","stage-0","stage-5"],"dependencies":[{"issue_id":"goon-game-e06","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e05","title":"EPIC-05 Puzzle Objects","description":"Кнопки, двери, платформы, весовые объекты и событийные сигналы. Разделы 11–14, 37.","acceptance_criteria":"Механизмы не связаны жёсткими ссылками и корректно работают с живым персонажем и телами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:53Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T04:38:26Z","closed_at":"2026-08-19T04:38:26Z","close_reason":"All puzzle-object children are complete: baseline switches/doors/platforms, event-driven PuzzleSystem and shared weighted physical objects. Player, corpse and authored objects now use data-driven uncoupled contracts with full regression evidence.","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","primary-stage-6","puzzle","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e05","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-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}
@@ -66,7 +66,7 @@
{"_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-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-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-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-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 одинаково работает с персонажем, телом и объектами.","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-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-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}
+15 -8
View File
@@ -13,13 +13,14 @@ Project documents:
- [`docs/STAGE5_QA_REPORT.md`](docs/STAGE5_QA_REPORT.md) — exact Vertical Slice candidate evidence; - [`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_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_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/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/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. - [`docs/BEADS_WORKFLOW.md`](docs/BEADS_WORKFLOW.md) — local backlog structure and daily Beads commands.
## Playable content ## Playable content
The APK contains seventeen sequential levels: The APK contains eighteen sequential levels:
1. movement and jumping; 1. movement and jumping;
2. bodies covering a spike lane; 2. bodies covering a spike lane;
@@ -37,9 +38,10 @@ The APK contains seventeen sequential levels:
14. explosion delivery to a remote pressure door; 14. explosion delivery to a remote pressure door;
15. a crushed body whose low collider becomes a required step; 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; 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. 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 ## Controls
@@ -81,7 +83,7 @@ Previews are written to `level-format/build/level-previews`. Invalid geometry,
versions, moving-platform destinations, switch parameters, or signal references versions, moving-platform destinations, switch parameters, or signal references
fail the build with a JSON path diagnostic. fail the build with a JSON path diagnostic.
All seventeen 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 `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 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 scenario for an expired timer on the far side of the pit. Stage 2 additionally
@@ -119,14 +121,19 @@ deterministic character state machine, a bounded `CorpseManager`, and an
event-driven `PuzzleSystem` with explicit signal and door transitions. The first event-driven `PuzzleSystem` with explicit signal and door transitions. The first
mechanics slice adds data-driven crusher, fire, projectile and explosion hazards, mechanics slice adds data-driven crusher, fire, projectile and explosion hazards,
teaching levels 11–14, dynamic level pagination and production-input routes. teaching levels 11–14, dynamic level pagination and production-input routes.
Burned/crushed corpse states are taught in levels 15–16, and level 17 adds five Burned/crushed corpse states are taught in levels 15–16, level 17 adds five
authored physical-object types over one shared pressure-mass contract. 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`, Scope and final evidence are in `docs/STAGE6_CHARACTER_STATES.md`,
`docs/STAGE6_CORPSE_MANAGER.md`, and `docs/STAGE6_PUZZLE_SYSTEM.md`; the latest `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 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 contract is in `docs/STAGE6_CORPSE_STATES.md`. Weighted-object evidence is in
`docs/STAGE6_WEIGHTED_OBJECTS.md`. The latest exact gate is `108 app JVM + 2 `docs/STAGE6_WEIGHTED_OBJECTS.md`; challenge and migration evidence is in
content-tool JVM / 48 Android` on the seventeen-level candidate. `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: 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:
@@ -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)));
}
}
}
@@ -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();
}
}
@@ -218,8 +218,10 @@ public final class LateLevelPlaythroughTest {
@Override @Override
public void startLevel(int oneBasedLevelNumber) { public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync( instrumentation.runOnMainSync(() -> {
() -> gameView.startLevelForAutomation(oneBasedLevelNumber)); gameView.enableDeterministicAutomationClock();
gameView.startLevelForAutomation(oneBasedLevelNumber);
});
} }
@Override @Override
@@ -238,19 +240,16 @@ public final class LateLevelPlaythroughTest {
@Override @Override
public void waitForNextDecision(long millis) { public void waitForNextDecision(long millis) {
try {
boolean inFinalTraversalWindow = throttleFinalTraversalOnly boolean inFinalTraversalWindow = throttleFinalTraversalOnly
&& lastSnapshot != null && lastSnapshot != null
&& lastSnapshot.levelNumber() == 10 && lastSnapshot.levelNumber() == 10
&& lastSnapshot.deaths() == 3 && lastSnapshot.deaths() == 3
&& lastSnapshot.playerX() >= 1_200f && lastSnapshot.playerX() >= 1_200f
&& lastSnapshot.playerX() <= 2_200f; && lastSnapshot.playerX() <= 2_200f;
Thread.sleep(inFinalTraversalWindow long decisionMillis = inFinalTraversalWindow
? Math.max(millis, minimumDecisionMillis) : millis); ? Math.max(millis, minimumDecisionMillis) : millis;
} catch (InterruptedException interrupted) { instrumentation.runOnMainSync(
Thread.currentThread().interrupt(); () -> gameView.advanceDeterministicAutomation(decisionMillis));
throw new AssertionError("Late-level bot interrupted", interrupted);
}
} }
} }
} }
@@ -38,6 +38,7 @@ public final class LevelPaginationTest {
assertTrue(visible.contains("Steam Charge")); assertTrue(visible.contains("Steam Charge"));
assertTrue(visible.contains("Ashen Counterweight")); assertTrue(visible.contains("Ashen Counterweight"));
assertTrue(visible.contains("The Whole Weight")); assertTrue(visible.contains("The Whole Weight"));
assertTrue(visible.contains("Threefold Proof"));
assertFalse(visible.contains("The Volunteer")); assertFalse(visible.contains("The Volunteer"));
assertTrue(visible.contains("Page 2 of 2")); assertTrue(visible.contains("Page 2 of 2"));
}); });
@@ -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; }
}
@@ -5,6 +5,7 @@ public final class CompletionResult {
public enum NextGoal { public enum NextGoal {
TARGET_DEATHS, TARGET_DEATHS,
TARGET_TIME, TARGET_TIME,
TARGET_COLLECTIBLE,
NEXT_LEVEL, NEXT_LEVEL,
PERSONAL_BEST PERSONAL_BEST
} }
@@ -15,12 +16,15 @@ public final class CompletionResult {
private final long bestTimeMs; private final long bestTimeMs;
private final int targetDeaths; private final int targetDeaths;
private final long targetTimeMs; private final long targetTimeMs;
private final ChallengeResult challengeResult;
private final int bestStars;
private final NextGoal nextGoal; private final NextGoal nextGoal;
private final long nextGoalValue; private final long nextGoalValue;
private CompletionResult(int currentDeaths, long currentTimeMs, private CompletionResult(int currentDeaths, long currentTimeMs,
int bestDeaths, long bestTimeMs, int bestDeaths, long bestTimeMs,
int targetDeaths, long targetTimeMs, int targetDeaths, long targetTimeMs,
ChallengeResult challengeResult, int bestStars,
NextGoal nextGoal, long nextGoalValue) { NextGoal nextGoal, long nextGoalValue) {
this.currentDeaths = currentDeaths; this.currentDeaths = currentDeaths;
this.currentTimeMs = currentTimeMs; this.currentTimeMs = currentTimeMs;
@@ -28,6 +32,8 @@ public final class CompletionResult {
this.bestTimeMs = bestTimeMs; this.bestTimeMs = bestTimeMs;
this.targetDeaths = targetDeaths; this.targetDeaths = targetDeaths;
this.targetTimeMs = targetTimeMs; this.targetTimeMs = targetTimeMs;
this.challengeResult = challengeResult;
this.bestStars = bestStars;
this.nextGoal = nextGoal; this.nextGoal = nextGoal;
this.nextGoalValue = nextGoalValue; this.nextGoalValue = nextGoalValue;
} }
@@ -36,7 +42,21 @@ public final class CompletionResult {
int currentDeaths, long currentTimeMs, int currentDeaths, long currentTimeMs,
int bestDeaths, long bestTimeMs, int bestDeaths, long bestTimeMs,
boolean hasNextLevel) { 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; long targetTimeMs = level.targetSeconds * 1_000L;
ChallengeResult currentChallenges = ChallengeResult.evaluate(
level, currentDeaths, currentTimeMs, currentCollectible);
ChallengeResult bestChallenges = ChallengeResult.evaluate(
level, bestDeaths, bestTimeMs, bestCollectible);
NextGoal goal; NextGoal goal;
long goalValue; long goalValue;
if (currentDeaths > level.targetDeaths) { if (currentDeaths > level.targetDeaths) {
@@ -45,6 +65,9 @@ public final class CompletionResult {
} else if (currentTimeMs > targetTimeMs) { } else if (currentTimeMs > targetTimeMs) {
goal = NextGoal.TARGET_TIME; goal = NextGoal.TARGET_TIME;
goalValue = targetTimeMs; goalValue = targetTimeMs;
} else if (!bestChallenges.collectibleEarned()) {
goal = NextGoal.TARGET_COLLECTIBLE;
goalValue = 0L;
} else if (hasNextLevel) { } else if (hasNextLevel) {
goal = NextGoal.NEXT_LEVEL; goal = NextGoal.NEXT_LEVEL;
goalValue = level.number + 1L; goalValue = level.number + 1L;
@@ -53,7 +76,8 @@ public final class CompletionResult {
goalValue = 0L; goalValue = 0L;
} }
return new CompletionResult(currentDeaths, currentTimeMs, bestDeaths, bestTimeMs, 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; } public int currentDeaths() { return currentDeaths; }
@@ -62,6 +86,8 @@ public final class CompletionResult {
public long bestTimeMs() { return bestTimeMs; } public long bestTimeMs() { return bestTimeMs; }
public int targetDeaths() { return targetDeaths; } public int targetDeaths() { return targetDeaths; }
public long targetTimeMs() { return targetTimeMs; } public long targetTimeMs() { return targetTimeMs; }
public ChallengeResult challengeResult() { return challengeResult; }
public int bestStars() { return bestStars; }
public NextGoal nextGoal() { return nextGoal; } public NextGoal nextGoal() { return nextGoal; }
public long nextGoalValue() { return nextGoalValue; } public long nextGoalValue() { return nextGoalValue; }
} }
@@ -133,6 +133,7 @@ public final class GameSessionSnapshot {
private float elapsedSeconds; private float elapsedSeconds;
private int deaths; private int deaths;
private float respawnSeconds; private float respawnSeconds;
private boolean collectibleFound;
private PlayerData player = new PlayerData( private PlayerData player = new PlayerData(
0f, 0f, 0f, 0f, true, false, 0f, 0f, -1, 1); 0f, 0f, 0f, 0f, true, false, 0f, 0f, -1, 1);
private final ArrayList<BodyData> bodies = new ArrayList<>(); private final ArrayList<BodyData> bodies = new ArrayList<>();
@@ -151,6 +152,7 @@ public final class GameSessionSnapshot {
public Builder elapsedSeconds(float value) { elapsedSeconds = value; return this; } public Builder elapsedSeconds(float value) { elapsedSeconds = value; return this; }
public Builder deaths(int value) { deaths = value; return this; } public Builder deaths(int value) { deaths = value; return this; }
public Builder respawnSeconds(float value) { respawnSeconds = 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 player(PlayerData value) { player = value; return this; }
public Builder addBody(BodyData value) { bodies.add(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 addObject(ObjectData value) { objects.add(value); return this; }
@@ -179,6 +181,7 @@ public final class GameSessionSnapshot {
private final float elapsedSeconds; private final float elapsedSeconds;
private final int deaths; private final int deaths;
private final float respawnSeconds; private final float respawnSeconds;
private final boolean collectibleFound;
private final PlayerData player; private final PlayerData player;
private final List<BodyData> bodies; private final List<BodyData> bodies;
private final List<ObjectData> objects; private final List<ObjectData> objects;
@@ -193,6 +196,7 @@ public final class GameSessionSnapshot {
elapsedSeconds = builder.elapsedSeconds; elapsedSeconds = builder.elapsedSeconds;
deaths = builder.deaths; deaths = builder.deaths;
respawnSeconds = builder.respawnSeconds; respawnSeconds = builder.respawnSeconds;
collectibleFound = builder.collectibleFound;
player = builder.player; player = builder.player;
bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies)); bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies));
objects = Collections.unmodifiableList(new ArrayList<>(builder.objects)); objects = Collections.unmodifiableList(new ArrayList<>(builder.objects));
@@ -211,6 +215,7 @@ public final class GameSessionSnapshot {
public float elapsedSeconds() { return elapsedSeconds; } public float elapsedSeconds() { return elapsedSeconds; }
public int deaths() { return deaths; } public int deaths() { return deaths; }
public float respawnSeconds() { return respawnSeconds; } public float respawnSeconds() { return respawnSeconds; }
public boolean collectibleFound() { return collectibleFound; }
public PlayerData player() { return player; } public PlayerData player() { return player; }
public List<BodyData> bodies() { return bodies; } public List<BodyData> bodies() { return bodies; }
public List<ObjectData> objects() { return objects; } public List<ObjectData> objects() { return objects; }
@@ -126,6 +126,20 @@ public final class GameStrings {
return (earned ? "★" : "☆") + " " + GameMath.formatTime(elapsedMilliseconds) return (earned ? "★" : "☆") + " " + GameMath.formatTime(elapsedMilliseconds)
+ " / " + GameMath.formatTime(targetMilliseconds); + " / " + 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 levelSelect() { return russian() ? "Выбор уровня" : "Level select"; }
public String replay() { return russian() ? "Повторить" : "Replay"; } public String replay() { return russian() ? "Повторить" : "Replay"; }
public String nextLevel() { return russian() ? "Следующий уровень" : "Next level"; } public String nextLevel() { return russian() ? "Следующий уровень" : "Next level"; }
@@ -166,6 +180,11 @@ public final class GameStrings {
+ GameMath.formatTime(timeMs); + GameMath.formatTime(timeMs);
} }
public String nextGoalCollectible() {
return russian() ? "Следующая цель · найти тайник"
: "Next goal · find the cache";
}
public String nextGoalLevel(long level) { public String nextGoalLevel(long level) {
return (russian() ? "Следующая цель · перейти на уровень " return (russian() ? "Следующая цель · перейти на уровень "
: "Next goal · enter level ") + level; : "Next goal · enter level ") + level;
@@ -85,6 +85,8 @@ public final class GameView extends View implements Runnable {
private float firstPhysicalObjectY = Float.NaN; private float firstPhysicalObjectY = Float.NaN;
private int firstPhysicalObjectMass; private int firstPhysicalObjectMass;
private String firstPhysicalObjectType = ""; private String firstPhysicalObjectType = "";
private boolean collectibleFound;
private boolean collectibleVisible;
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber, boolean visiblyPaused, boolean inputActive, int levelNumber,
@@ -247,6 +249,37 @@ public final class GameView extends View implements Runnable {
int physicalObjectCount, float firstPhysicalObjectX, int physicalObjectCount, float firstPhysicalObjectX,
float firstPhysicalObjectY, int firstPhysicalObjectMass, float firstPhysicalObjectY, int firstPhysicalObjectMass,
String firstPhysicalObjectType) { String firstPhysicalObjectType) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
firstMovingPlatformPowered, firstMovingPlatformMovingRight,
firstMovingPlatformMovingDown, firstCorpseX, playerOnGround,
playerVelocityX, playerVelocityY, firstHazardActive, firstHazardX,
firstHazardY, firstCorpseBurned, firstCorpseCrushed,
firstCorpseActivatesPressure, firstCorpseWidth, firstCorpseHeight,
physicalObjectCount, firstPhysicalObjectX, firstPhysicalObjectY,
firstPhysicalObjectMass, firstPhysicalObjectType, false, false);
}
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
long elapsedMilliseconds,
float playerX, float playerY, int deaths, int corpseCount,
float firstMovingPlatformX, float firstMovingPlatformY,
boolean playerOnMovingPlatform,
boolean firstMovingPlatformPowered,
boolean firstMovingPlatformMovingRight,
boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround, float playerVelocityX,
float playerVelocityY, boolean firstHazardActive,
float firstHazardX, float firstHazardY,
boolean firstCorpseBurned, boolean firstCorpseCrushed,
boolean firstCorpseActivatesPressure,
float firstCorpseWidth, float firstCorpseHeight,
int physicalObjectCount, float firstPhysicalObjectX,
float firstPhysicalObjectY, int firstPhysicalObjectMass,
String firstPhysicalObjectType,
boolean collectibleFound, boolean collectibleVisible) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount, elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
@@ -261,6 +294,8 @@ public final class GameView extends View implements Runnable {
this.firstPhysicalObjectMass = firstPhysicalObjectMass; this.firstPhysicalObjectMass = firstPhysicalObjectMass;
this.firstPhysicalObjectType = firstPhysicalObjectType == null this.firstPhysicalObjectType = firstPhysicalObjectType == null
? "" : firstPhysicalObjectType; ? "" : firstPhysicalObjectType;
this.collectibleFound = collectibleFound;
this.collectibleVisible = collectibleVisible;
} }
public boolean completed() { return completed; } public boolean completed() { return completed; }
@@ -299,6 +334,8 @@ public final class GameView extends View implements Runnable {
public float firstPhysicalObjectY() { return firstPhysicalObjectY; } public float firstPhysicalObjectY() { return firstPhysicalObjectY; }
public int firstPhysicalObjectMass() { return firstPhysicalObjectMass; } public int firstPhysicalObjectMass() { return firstPhysicalObjectMass; }
public String firstPhysicalObjectType() { return firstPhysicalObjectType; } public String firstPhysicalObjectType() { return firstPhysicalObjectType; }
public boolean collectibleFound() { return collectibleFound; }
public boolean collectibleVisible() { return collectibleVisible; }
} }
private static final class Player implements IWeightedObject { private static final class Player implements IWeightedObject {
@@ -399,6 +436,7 @@ public final class GameView extends View implements Runnable {
private AudioSettings audioSettings; private AudioSettings audioSettings;
private float lastFootstepAt = -10f; private float lastFootstepAt = -10f;
private boolean deterministicAutomationClock; private boolean deterministicAutomationClock;
private boolean collectibleFound;
private double deterministicAccumulatorSeconds; private double deterministicAccumulatorSeconds;
private float lastDrawDurationMs; private float lastDrawDurationMs;
private int performanceFrameCounter; private int performanceFrameCounter;
@@ -528,6 +566,7 @@ public final class GameView extends View implements Runnable {
previousSwitchContact.clear(); previousSwitchContact.clear();
configurePuzzleSystem(); configurePuzzleSystem();
completionResult = null; completionResult = null;
collectibleFound = false;
configureAccessibilitySignals(); configureAccessibilitySignals();
invalidateHudTextCache(); invalidateHudTextCache();
for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item));
@@ -582,8 +621,11 @@ public final class GameView extends View implements Runnable {
if (respawnTimer <= 0f) spawnPlayer(); if (respawnTimer <= 0f) spawnPlayer();
} else { } else {
checkHazards(); checkHazards();
if (player.alive) {
checkCollectible();
checkGoal(); checkGoal();
} }
}
if (player.alive && player.onGround && Math.abs(player.vx) >= 100f if (player.alive && player.onGround && Math.abs(player.vx) >= 100f
&& levelElapsed - lastFootstepAt >= 0.25f) { && levelElapsed - lastFootstepAt >= 0.25f) {
@@ -1129,17 +1171,28 @@ public final class GameView extends View implements Runnable {
finishLevel(deaths, (long) (levelElapsed * 1000f)); finishLevel(deaths, (long) (levelElapsed * 1000f));
} }
private void checkCollectible() {
if (collectibleFound || level.collectible == null) return;
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.collectible)) return;
collectibleFound = true;
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
audioEngine.play(AudioCue.SWITCH);
refreshAccessibilityDescription(true);
}
private void finishLevel(int completedDeaths, long completedTimeMs) { private void finishLevel(int completedDeaths, long completedTimeMs) {
long safeTimeMs = Math.max(0L, completedTimeMs); long safeTimeMs = Math.max(0L, completedTimeMs);
deaths = Math.max(0, completedDeaths); deaths = Math.max(0, completedDeaths);
levelElapsed = safeTimeMs / 1_000f; levelElapsed = safeTimeMs / 1_000f;
progress.recordCompletion(level.number, deaths, safeTimeMs); progress.recordCompletion(level, deaths, safeTimeMs, collectibleFound);
completionResult = CompletionResult.create( completionResult = CompletionResult.create(
level, level,
deaths, deaths,
safeTimeMs, safeTimeMs,
progress.bestDeaths(level.number), progress.bestDeaths(level.number),
progress.bestTimeMs(level.number), progress.bestTimeMs(level.number),
collectibleFound,
progress.collectibleFound(level.levelId),
levelIndex + 1 < LevelCatalog.all().size() levelIndex + 1 < LevelCatalog.all().size()
); );
player.vx = player.vy = 0f; player.vx = player.vy = 0f;
@@ -1211,7 +1264,8 @@ public final class GameView extends View implements Runnable {
+ level.hazards.size() + level.switches.size() * actors; + level.hazards.size() + level.switches.size() * actors;
int drawPrimitives = 180 + platformStates.size() * 8 int drawPrimitives = 180 + platformStates.size() * 8
+ level.switches.size() * 9 + level.hazards.size() * 20 + level.switches.size() * 9 + level.hazards.size() * 20
+ level.doors.size() * 12 + bodies * 28 + objects * 8; + level.doors.size() * 12 + bodies * 28 + objects * 8
+ (level.collectible == null ? 0 : 7);
Runtime runtime = Runtime.getRuntime(); Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory(); long usedMemory = runtime.totalMemory() - runtime.freeMemory();
performanceMonitor.recordComplexity(bodies, collisionChecks, performanceMonitor.recordComplexity(bodies, collisionChecks,
@@ -1227,6 +1281,7 @@ public final class GameView extends View implements Runnable {
drawSwitches(canvas); drawSwitches(canvas);
drawHazards(canvas); drawHazards(canvas);
drawDoors(canvas); drawDoors(canvas);
drawCollectible(canvas);
drawGoal(canvas); drawGoal(canvas);
for (PhysicalObjectState object : physicalObjects) drawPhysicalObject(canvas, object); for (PhysicalObjectState object : physicalObjects) drawPhysicalObject(canvas, object);
for (Body corpse : corpses) drawCorpse(canvas, corpse); for (Body corpse : corpses) drawCorpse(canvas, corpse);
@@ -1547,6 +1602,26 @@ public final class GameView extends View implements Runnable {
canvas.drawCircle(box.centerX() - 6f, box.centerY() - 7f, 7f, paint); canvas.drawCircle(box.centerX() - 6f, box.centerY() - 7f, 7f, paint);
} }
private void drawCollectible(Canvas canvas) {
if (collectibleFound || level.collectible == null) return;
RectF box = rect(level.collectible);
float pulse = accessibilitySettings.reducedMotionEnabled()
? 0f : (float) Math.sin(levelElapsed * 4f) * 5f;
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : Color.rgb(250, 211, 86));
path.reset();
path.moveTo(box.centerX(), box.top - pulse);
path.lineTo(box.right + pulse, box.centerY());
path.lineTo(box.centerX(), box.bottom + pulse);
path.lineTo(box.left - pulse, box.centerY());
path.close();
canvas.drawPath(path, paint);
stroke.setColor(Color.BLACK);
stroke.setStrokeWidth(4f);
canvas.drawPath(path, stroke);
drawWorldLabel(canvas, "★ CACHE", box.centerX(), box.top - 18f);
}
private void drawCorpse(Canvas canvas, Body corpse) { private void drawCorpse(Canvas canvas, Body corpse) {
if (corpse.crushed) { if (corpse.crushed) {
paint.setColor(accessibilitySettings.highContrastEnabled() paint.setColor(accessibilitySettings.highContrastEnabled()
@@ -1723,8 +1798,10 @@ public final class GameView extends View implements Runnable {
if (complete) { if (complete) {
int bestDeaths = progress.bestDeaths(i + 1); int bestDeaths = progress.bestDeaths(i + 1);
long bestTime = progress.bestTimeMs(i + 1); long bestTime = progress.bestTimeMs(i + 1);
drawCenteredText(canvas, strings.starRow(progress.earnedStars(item)),
card.centerX(), top + 171, 34, Color.rgb(250, 211, 111));
drawCenteredFittedText(canvas, strings.levelBestStats(bestDeaths, bestTime), drawCenteredFittedText(canvas, strings.levelBestStats(bestDeaths, bestTime),
card.centerX(), top + 185, 21, 17, cardW - 24, card.centerX(), top + 209, 21, 17, cardW - 24,
Color.rgb(138, 231, 163)); Color.rgb(138, 231, 163));
} else if (enabled) { } else if (enabled) {
drawCenteredText(canvas, strings.tapToEnter(), card.centerX(), top + 190, drawCenteredText(canvas, strings.tapToEnter(), card.centerX(), top + 190,
@@ -1829,14 +1906,26 @@ public final class GameView extends View implements Runnable {
if (result != null) { if (result != null) {
drawCenteredText(canvas, strings.currentResultLine(result.currentDeaths(), drawCenteredText(canvas, strings.currentResultLine(result.currentDeaths(),
result.currentTimeMs()), result.currentTimeMs()),
VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE); VIEW_WIDTH * 0.5f, 340, 32, Color.WHITE);
drawCenteredText(canvas, strings.bestResultLine(result.bestDeaths(), drawCenteredText(canvas, strings.bestResultLine(result.bestDeaths(),
result.bestTimeMs()), result.bestTimeMs()),
VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153)); VIEW_WIDTH * 0.5f, 389, 29, Color.rgb(128, 229, 153));
drawCenteredText(canvas, strings.targetsResultLine(result.targetDeaths(), drawCenteredText(canvas, strings.targetsResultLine(result.targetDeaths(),
result.targetTimeMs()), result.targetTimeMs()),
VIEW_WIDTH * 0.5f, 510, 28, Color.rgb(210, 211, 218)); VIEW_WIDTH * 0.5f, 430, 27, Color.rgb(210, 211, 218));
drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 585, 31, ChallengeResult challenges = result.challengeResult();
drawCenteredText(canvas, strings.starRow(result.bestStars()),
VIEW_WIDTH * 0.5f, 472, 43, Color.rgb(250, 211, 111));
drawCenteredText(canvas, strings.completionDeaths(challenges.casualtyEarned(),
result.currentDeaths(), result.targetDeaths()),
VIEW_WIDTH * 0.5f, 518, 27, Color.rgb(210, 211, 218));
drawCenteredText(canvas, strings.completionTime(challenges.timeEarned(),
result.currentTimeMs(), result.targetTimeMs()),
VIEW_WIDTH * 0.5f, 558, 27, Color.rgb(210, 211, 218));
drawCenteredText(canvas, strings.completionCollectible(
challenges.collectibleEarned(), challenges.collectibleAvailable()),
VIEW_WIDTH * 0.5f, 598, 27, Color.rgb(210, 211, 218));
drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 657, 29,
Color.rgb(250, 211, 111)); Color.rgb(250, 211, 111));
} }
@@ -1853,6 +1942,8 @@ public final class GameView extends View implements Runnable {
return strings.nextGoalDeaths(result.nextGoalValue()); return strings.nextGoalDeaths(result.nextGoalValue());
case TARGET_TIME: case TARGET_TIME:
return strings.nextGoalTime(result.nextGoalValue()); return strings.nextGoalTime(result.nextGoalValue());
case TARGET_COLLECTIBLE:
return strings.nextGoalCollectible();
case NEXT_LEVEL: case NEXT_LEVEL:
return strings.nextGoalLevel(result.nextGoalValue()); return strings.nextGoalLevel(result.nextGoalValue());
case PERSONAL_BEST: case PERSONAL_BEST:
@@ -2437,6 +2528,7 @@ public final class GameView extends View implements Runnable {
visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked()); visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked());
visible.add(strings.levelTitle(i + 1)); visible.add(strings.levelTitle(i + 1));
if (progress.isCompleted(i + 1)) { if (progress.isCompleted(i + 1)) {
visible.add(strings.starRow(progress.earnedStars(LevelCatalog.get(i))));
visible.add(strings.levelBestStats(progress.bestDeaths(i + 1), visible.add(strings.levelBestStats(progress.bestDeaths(i + 1),
progress.bestTimeMs(i + 1))); progress.bestTimeMs(i + 1)));
} else if (enabled) { } else if (enabled) {
@@ -2492,6 +2584,14 @@ public final class GameView extends View implements Runnable {
completionResult.bestTimeMs())); completionResult.bestTimeMs()));
visible.add(strings.targetsResultLine(completionResult.targetDeaths(), visible.add(strings.targetsResultLine(completionResult.targetDeaths(),
completionResult.targetTimeMs())); completionResult.targetTimeMs()));
ChallengeResult challenges = completionResult.challengeResult();
visible.add(strings.starRow(completionResult.bestStars()));
visible.add(strings.completionDeaths(challenges.casualtyEarned(),
completionResult.currentDeaths(), completionResult.targetDeaths()));
visible.add(strings.completionTime(challenges.timeEarned(),
completionResult.currentTimeMs(), completionResult.targetTimeMs()));
visible.add(strings.completionCollectible(challenges.collectibleEarned(),
challenges.collectibleAvailable()));
visible.add(nextGoalText(completionResult)); visible.add(nextGoalText(completionResult));
} }
visible.add(strings.levelSelect()); visible.add(strings.levelSelect());
@@ -2797,6 +2897,7 @@ public final class GameView extends View implements Runnable {
.elapsedSeconds(levelElapsed) .elapsedSeconds(levelElapsed)
.deaths(deaths) .deaths(deaths)
.respawnSeconds(Math.max(0f, respawnTimer)) .respawnSeconds(Math.max(0f, respawnTimer))
.collectibleFound(collectibleFound)
.player(new GameSessionSnapshot.PlayerData( .player(new GameSessionSnapshot.PlayerData(
player.x, player.y, player.vx, player.vy, player.x, player.y, player.vx, player.vy,
player.alive, player.onGround, player.coyote, player.alive, player.onGround, player.coyote,
@@ -2860,6 +2961,7 @@ public final class GameView extends View implements Runnable {
startLevel(index); startLevel(index);
levelElapsed = snapshot.elapsedSeconds(); levelElapsed = snapshot.elapsedSeconds();
collectibleFound = snapshot.collectibleFound();
for (HazardRuntime runtime : hazardRuntimes) runtime.update(levelElapsed); for (HazardRuntime runtime : hazardRuntimes) runtime.update(levelElapsed);
deaths = snapshot.deaths(); deaths = snapshot.deaths();
respawnTimer = snapshot.respawnSeconds(); respawnTimer = snapshot.respawnSeconds();
@@ -3012,7 +3114,9 @@ public final class GameView extends View implements Runnable {
firstObject == null ? Float.NaN : firstObject.x, firstObject == null ? Float.NaN : firstObject.x,
firstObject == null ? Float.NaN : firstObject.y, firstObject == null ? Float.NaN : firstObject.y,
firstObject == null ? 0 : firstObject.mass, firstObject == null ? 0 : firstObject.mass,
firstObject == null ? "" : firstObject.type.name() firstObject == null ? "" : firstObject.type.name(),
collectibleFound,
level != null && level.collectible != null && !collectibleFound
); );
} }
@@ -5,7 +5,7 @@ import android.content.SharedPreferences;
public final class ProgressStore { public final class ProgressStore {
private static final String FILE = "project_sacrifice_progress_v1"; 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; private final SharedPreferences preferences;
public ProgressStore(Context context) { public ProgressStore(Context context) {
@@ -18,26 +18,60 @@ public final class ProgressStore {
} }
public boolean isCompleted(int levelNumber) { 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) { 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) { 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) { 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(); SharedPreferences.Editor editor = preferences.edit();
editor.putBoolean(key(levelNumber, "completed"), true); editor.putBoolean(idKey(level.levelId, "completed"), true);
editor.putInt(key(levelNumber, "deaths"), Math.min(deaths, bestDeaths(levelNumber))); editor.putInt(idKey(level.levelId, "deaths"),
editor.putLong(key(levelNumber, "time"), Math.min(elapsedMs, bestTimeMs(levelNumber))); Math.min(Math.max(0, deaths), bestDeaths(level.levelId)));
editor.putInt("unlocked", Math.min(LevelCatalog.all().size(), Math.max(unlockedCount(), levelNumber + 1))); 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(); 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() { public int saveVersion() {
return preferences.getInt("save_version", 1); return preferences.getInt("save_version", 1);
} }
@@ -45,11 +79,56 @@ public final class ProgressStore {
private void migrateIfSupported() { private void migrateIfSupported() {
int version = preferences.getInt("save_version", 1); int version = preferences.getInt("save_version", 1);
if (version >= 1 && version < CURRENT_SAVE_VERSION) { 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) { private static String key(int levelNumber, String suffix) {
return "level_" + levelNumber + "_" + suffix; return "level_" + levelNumber + "_" + suffix;
} }
private static String idKey(String levelId, String suffix) {
return "level_id_" + levelId + "_" + suffix;
}
} }
@@ -15,7 +15,7 @@ public final class VersionedSessionCodec {
private static final int MAX_OBJECTS = 64; private static final int MAX_OBJECTS = 64;
private static final int MAX_PLATFORMS = 64; private static final int MAX_PLATFORMS = 64;
private static final int MAX_MAP_ENTRIES = 128; private static final int MAX_MAP_ENTRIES = 128;
public static final int CURRENT_VERSION = 4; public static final int CURRENT_VERSION = 5;
public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT } public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT }
@@ -50,6 +50,10 @@ public final class VersionedSessionCodec {
return encodeWithVersion(snapshot, 3); return encodeWithVersion(snapshot, 3);
} }
static byte[] encodeLegacyV4(GameSessionSnapshot snapshot) {
return encodeWithVersion(snapshot, 4);
}
static byte[] encodeVersionForTest(int version) { static byte[] encodeVersionForTest(int version) {
try { try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(); ByteArrayOutputStream bytes = new ByteArrayOutputStream();
@@ -89,6 +93,7 @@ public final class VersionedSessionCodec {
int objectCount = checkedCount(input.readInt(), MAX_OBJECTS); int objectCount = checkedCount(input.readInt(), MAX_OBJECTS);
for (int i = 0; i < objectCount; i++) builder.addObject(readObject(input)); 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); int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS);
ArrayList<Float> motionTimes = new ArrayList<>(platformCount); ArrayList<Float> motionTimes = new ArrayList<>(platformCount);
@@ -130,6 +135,7 @@ public final class VersionedSessionCodec {
writeObject(output, object); writeObject(output, object);
} }
} }
if (version >= 5) output.writeBoolean(snapshot.collectibleFound());
output.writeInt(snapshot.platformMotionTimes().size()); output.writeInt(snapshot.platformMotionTimes().size());
for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time); for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time);
writeBooleanMap(output, snapshot.toggleSignals()); writeBooleanMap(output, snapshot.toggleSignals());
@@ -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());
}
}
@@ -42,7 +42,35 @@ public final class CompletionResultTest {
LevelDefinition last = LevelCatalog.get(LevelCatalog.all().size() - 1); LevelDefinition last = LevelCatalog.get(LevelCatalog.all().size() - 1);
CompletionResult lastResult = CompletionResult.create(last, CompletionResult lastResult = CompletionResult.create(last,
last.targetDeaths, last.targetSeconds * 900L, 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()); 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());
}
} }
@@ -33,6 +33,10 @@ public final class GameStringsTest {
assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L)); assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L));
assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L)); assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L));
assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3)); 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()); assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle());
assertFalse(russian.tutorialSacrificeBody().trim().isEmpty()); assertFalse(russian.tutorialSacrificeBody().trim().isEmpty());
@@ -1,6 +1,8 @@
package com.projectsacrifice.game; package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Test; import org.junit.Test;
@@ -26,4 +28,26 @@ public final class LevelCatalogLoaderTest {
assertEquals(1, levels.get(0).number); assertEquals(1, levels.get(0).number);
assertEquals("The Test", levels.get(0).title); 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'"));
}
}
} }
@@ -13,9 +13,9 @@ import java.util.Set;
public final class LevelCatalogTest { public final class LevelCatalogTest {
@Test @Test
public void productionCatalogContainsHazardsAndTwoCorpseStateTeachingLevels() { public void productionCatalogContainsMechanicsAndChallengeTeachingLevels() {
List<LevelDefinition> levels = LevelCatalog.all(); List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(17, levels.size()); assertEquals(18, levels.size());
Set<String> titles = new HashSet<>(); Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) { for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i); LevelDefinition level = levels.get(i);
@@ -40,6 +40,10 @@ public final class LevelCatalogTest {
objectTypes); objectTypes);
assertEquals(9, weighted.switches.get(0).requiredMass); assertEquals(9, weighted.switches.get(0).requiredMass);
assertFalse(weighted.doors.isEmpty()); assertFalse(weighted.doors.isEmpty());
LevelDefinition challenge = levels.get(17);
assertEquals("foundations.threefold-proof", challenge.levelId);
assertEquals("foundations", challenge.worldId);
assertTrue(challenge.collectible != null);
} }
@Test @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());
}
}
@@ -251,6 +251,36 @@ public final class LevelJsonLoaderTest {
} }
} }
@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) { static String validLevelJson(int formatVersion) {
return "{" return "{"
+ "\"formatVersion\":" + formatVersion + "," + "\"formatVersion\":" + formatVersion + ","
@@ -8,8 +8,8 @@ import org.junit.Test;
public final class LevelPageTest { public final class LevelPageTest {
@Test @Test
public void seventeenLevelsUseTwoBoundedTenCardPages() { public void eighteenLevelsUseTwoBoundedTenCardPages() {
LevelPage page = new LevelPage(17, 10); LevelPage page = new LevelPage(18, 10);
assertEquals(2, page.pageCount()); assertEquals(2, page.pageCount());
assertEquals(0, page.firstIndex()); assertEquals(0, page.firstIndex());
@@ -19,7 +19,7 @@ public final class LevelPageTest {
page.next(); page.next();
assertEquals(10, page.firstIndex()); assertEquals(10, page.firstIndex());
assertEquals(17, page.endExclusive()); assertEquals(18, page.endExclusive());
assertTrue(page.hasPrevious()); assertTrue(page.hasPrevious());
assertFalse(page.hasNext()); assertFalse(page.hasNext());
@@ -31,5 +31,7 @@ public final class LevelPageTest {
assertEquals(1, page.pageIndex()); assertEquals(1, page.pageIndex());
page.selectIndex(16); page.selectIndex(16);
assertEquals(1, page.pageIndex()); assertEquals(1, page.pageIndex());
page.selectIndex(17);
assertEquals(1, page.pageIndex());
} }
} }
@@ -33,6 +33,7 @@ public final class VersionedSessionCodecTest {
assertEquals(LevelDefinition.PhysicalObjectType.BOX, assertEquals(LevelDefinition.PhysicalObjectType.BOX,
restored.objects().get(0).type()); restored.objects().get(0).type());
assertEquals(3, restored.objects().get(1).mass()); assertEquals(3, restored.objects().get(1).mass());
assertTrue(restored.collectibleFound());
assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes()); assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes());
assertTrue(restored.toggleSignals().get("gate")); assertTrue(restored.toggleSignals().get("gate"));
assertEquals(2.25f, restored.timedSignals().get("clock"), 0f); assertEquals(2.25f, restored.timedSignals().get("clock"), 0f);
@@ -76,6 +77,16 @@ public final class VersionedSessionCodecTest {
assertTrue(decoded.snapshot().objects().isEmpty()); 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 @Test
public void corruptAndFutureSnapshotsFailClosed() { public void corruptAndFutureSnapshotsFailClosed() {
VersionedSessionCodec.DecodeResult corrupt = VersionedSessionCodec.DecodeResult corrupt =
@@ -109,6 +120,7 @@ public final class VersionedSessionCodecTest {
return GameSessionSnapshot.builder(5) return GameSessionSnapshot.builder(5)
.elapsedSeconds(12.75f) .elapsedSeconds(12.75f)
.deaths(2) .deaths(2)
.collectibleFound(true)
.respawnSeconds(0.4f) .respawnSeconds(0.4f)
.player(new GameSessionSnapshot.PlayerData( .player(new GameSessionSnapshot.PlayerData(
640f, 780f, 120f, -40f, false, false, 640f, 780f, 120f, -40f, false, false,
+10 -4
View File
@@ -161,10 +161,11 @@ baseline-задачи также имели явного владельца по
`STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 6 разблокирован. `STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 6 разблокирован.
Текущий статус Stage 6: foundation, production hazards, burned/crushed corpse Текущий статус Stage 6: foundation, production hazards, burned/crushed corpse
states и weighted objects приняты. Каталог содержит 17 уровней; последний exact states, weighted objects и challenge/progression приняты. Каталог содержит 18
candidate прошёл `108` app JVM, `2` content-tool JVM и нефильтрованную Android 16 уровней. Stable `levelId`/`worldId`, casualty/time/cache challenges, progress-v3
матрицу `48/48` за `2,223.695 s`. Следующий dependency-order slice — challenges migration, session-v5 collectible state и persistent three-star result прошли
и production progression, после него начинается массовое производство четырёх `116` app JVM, `2` content-tool JVM и нефильтрованную Android 16 матрицу `52/52`
за `551.773 s`. Следующий dependency-order slice — массовое производство четырёх
миров. миров.
### Этап 6. Production content — P1, несколько итераций ### Этап 6. Production content — P1, несколько итераций
@@ -202,6 +203,11 @@ data-driven модель. Завершён первый mechanics slice: product
Box/Rock/Ball/IceBlock/Counterweight, save v4 и обучающий уровень 17 прошли clean Box/Rock/Ball/IceBlock/Counterweight, save v4 и обучающий уровень 17 прошли clean
gate `108` app JVM + `2` content-tool JVM и unfiltered Android matrix `48/48` gate `108` app JVM + `2` content-tool JVM и unfiltered Android matrix `48/48`
(`2,223.695 s`). Evidence зафиксирован в `STAGE6_WEIGHTED_OBJECTS.md`. (`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 дней ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
+30 -2
View File
@@ -1,8 +1,8 @@
# Графы решений уровней 1–17 # Графы решений уровней 1–18
Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения. Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения.
На 19 августа 2026 года intended route всех уровней 1–17 автоматически выполнен по три раза подряд без retry masking. Device-тесты управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`. На 19 августа 2026 года intended route всех уровней 1–18 автоматически выполнен по три раза подряд без retry masking. Device-тесты управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`.
## Семантика, общая для графов ## Семантика, общая для графов
@@ -434,6 +434,34 @@ save-v4 состояние. V1–v3 active sessions мигрируют с пус
до push `allDoorsOpen=false`; после push `firstPhysicalObjectX≥920` и Door open; до push `allDoorsOpen=false`; после push `firstPhysicalObjectX≥920` и Door open;
после release Door остаётся open; затем два отдельных jump edge и `completed=true`. после 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 принятых уровней по три раза и отдельный recovery route уровня 8. 1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8.
+12 -3
View File
@@ -39,6 +39,8 @@ desktop-команды используют один и тот же JSON parser,
{ {
"formatVersion": 1, "formatVersion": 1,
"physicsVersion": 1, "physicsVersion": 1,
"id": "foundations.dead-weight",
"worldId": "foundations",
"number": 3, "number": 3,
"copy": { "copy": {
"en": {"title": "Dead Weight", "hint": "Leave a body on the plate."}, "en": {"title": "Dead Weight", "hint": "Leave a body on the plate."},
@@ -47,6 +49,7 @@ desktop-команды используют один и тот же JSON parser,
"world": {"width": 2050, "height": 1080}, "world": {"width": 2050, "height": 1080},
"spawn": {"x": 130, "y": 760}, "spawn": {"x": 130, "y": 760},
"goal": {"x": 1810, "y": 770, "width": 86, "height": 110}, "goal": {"x": 1810, "y": 770, "width": 86, "height": 110},
"collectible": {"x": 850, "y": 650, "width": 58, "height": 58},
"targets": {"deaths": 1, "seconds": 50}, "targets": {"deaths": 1, "seconds": 50},
"objects": [ "objects": [
{"type": "COUNTERWEIGHT", "box": {"x": 300, "y": 780, "width": 80, "height": 100}, "mass": 3} {"type": "COUNTERWEIGHT", "box": {"x": 300, "y": 780, "width": 80, "height": 100}, "mass": 3}
@@ -71,6 +74,11 @@ desktop-команды используют один и тот же JSON parser,
Поддерживаемые значения: Поддерживаемые значения:
- `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`, - hazards: `SPIKES`, `SAW`, `FREEZER`, `ELECTRIC`, `PIT`, `CRUSHER`, `FIRE`,
`PROJECTILE`, `EXPLOSION`; `PROJECTILE`, `EXPLOSION`;
- physical objects: `BOX`, `ROCK`, `BALL`, `ICE_BLOCK`, `COUNTERWEIGHT`; каждый - physical objects: `BOX`, `ROCK`, `BALL`, `ICE_BLOCK`, `COUNTERWEIGHT`; каждый
@@ -88,7 +96,7 @@ desktop-команды используют один и тот же JSON parser,
- известные format/physics versions; - известные format/physics versions;
- конечные положительные размеры мира и объектов; - конечные положительные размеры мира и объектов;
- spawn, goal, physical objects, платформы, hazards, switches, doors и конечную позицию moving - стабильные `id`/`worldId`, spawn, goal, optional collectible, physical objects, платформы, hazards, switches, doors и конечную позицию moving
platform внутри мира; platform внутри мира;
- наличие хотя бы одной платформы; - наличие хотя бы одной платформы;
- неотрицательный target deaths и положительный target seconds; - неотрицательный target deaths и положительный target seconds;
@@ -96,7 +104,8 @@ desktop-команды используют один и тот же JSON parser,
`TIMED`; `TIMED`;
- существование каждого `door.requiredSignals` и `platform.controlId` среди - существование каждого `door.requiredSignals` и `platform.controlId` среди
сигналов switches/relays; сигналов switches/relays;
- целостность index, уникальность ресурсов и последовательную нумерацию. - целостность index, уникальность ресурсов и stable level IDs, последовательную
нумерацию.
Ошибка содержит путь, например: Ошибка содержит путь, например:
@@ -122,7 +131,7 @@ level-03.json.doors[0].requiredSignals[0] references missing signal 'missing'
Результат находится в `level-format/build/level-previews`: Результат находится в `level-format/build/level-previews`:
- `level-01.svg` … последний уровень каталога — world-space схема spawn/goal, - `level-01.svg` … последний уровень каталога — world-space схема spawn/goal,
objects, платформ, hazards, switches и doors; collectible, objects, платформ, hazards, switches и doors;
- `catalog.tsv` — координаты, размеры, object mass, travel и signal/control references. - `catalog.tsv` — координаты, размеры, object mass, travel и signal/control references.
Вывод использует стабильный порядок и locale-independent числа. Два запуска на Вывод использует стабильный порядок и locale-independent числа. Два запуска на
+70
View File
@@ -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`.
+1 -1
View File
@@ -65,7 +65,7 @@ Snapshot на 19 августа 2026 года:
| 3 | 5 | 0 | | 3 | 5 | 0 |
| 4 | 4 | 0 | | 4 | 4 | 0 |
| 5 | 15 | 0 | | 5 | 15 | 0 |
| 6 | 18 | 8 | | 6 | 18 | 6 |
| 7 | 9 | 9 | | 7 | 9 | 9 |
| 8 | 1 | 1 | | 8 | 1 | 1 |
| 9 | 1 | 1 | | 9 | 1 | 1 |
@@ -24,6 +24,7 @@ public final class LevelCatalogLoader {
List<LevelDefinition> result = new ArrayList<>(); List<LevelDefinition> result = new ArrayList<>();
Set<Integer> numbers = new HashSet<>(); Set<Integer> numbers = new HashSet<>();
Set<String> levelIds = new HashSet<>();
String directory = directory(index); String directory = directory(index);
for (int i = 0; i < entries.size(); i++) { for (int i = 0; i < entries.size(); i++) {
String path = directory + entries.get(i); String path = directory + entries.get(i);
@@ -37,6 +38,10 @@ public final class LevelCatalogLoader {
throw new IllegalArgumentException(index + ".levels[" + i throw new IllegalArgumentException(index + ".levels[" + i
+ "] duplicates level number " + level.number); + "] 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; int expected = i + 1;
if (level.number != expected) { if (level.number != expected) {
throw new IllegalArgumentException(index + ".levels[" + i + "] expected level number " throw new IllegalArgumentException(index + ".levels[" + i + "] expected level number "
@@ -89,6 +89,9 @@ public final class LevelContentTool {
} }
rect(value, "spawn", 0, new LevelDefinition.Box(level.spawnX, level.spawnY, 62f, 100f), "#22c55e"); rect(value, "spawn", 0, new LevelDefinition.Box(level.spawnX, level.spawnY, 62f, 100f), "#22c55e");
rect(value, "goal", 0, level.goal, "#38bdf8"); rect(value, "goal", 0, level.goal, "#38bdf8");
if (level.collectible != null) {
rect(value, "collectible", 0, level.collectible, "#facc15");
}
value.append("</svg>\n"); value.append("</svg>\n");
return value.toString(); return value.toString();
} }
@@ -107,6 +110,10 @@ public final class LevelContentTool {
row(value, level.number, "spawn", 0, "PLAYER", new LevelDefinition.Box( row(value, level.number, "spawn", 0, "PLAYER", new LevelDefinition.Box(
level.spawnX, level.spawnY, 62f, 100f), 0f, 0f, ""); level.spawnX, level.spawnY, 62f, 100f), 0f, 0f, "");
row(value, level.number, "goal", 0, "GOAL", level.goal, 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++) { for (int i = 0; i < level.platforms.size(); i++) {
LevelDefinition.Platform item = level.platforms.get(i); LevelDefinition.Platform item = level.platforms.get(i);
row(value, level.number, "platform", i, item.isMoving() ? "MOVING" : "STATIC", row(value, level.number, "platform", i, item.isMoving() ? "MOVING" : "STATIC",
@@ -131,6 +131,8 @@ public final class LevelDefinition {
public final int number; public final int number;
public final int formatVersion; public final int formatVersion;
public final int physicsVersion; public final int physicsVersion;
public final String levelId;
public final String worldId;
public final String title; public final String title;
public final String hint; public final String hint;
public final String titleRu; public final String titleRu;
@@ -142,6 +144,7 @@ public final class LevelDefinition {
public final Box goal; public final Box goal;
public final int targetDeaths; public final int targetDeaths;
public final int targetSeconds; public final int targetSeconds;
public final Box collectible;
public final List<PhysicalObject> objects; public final List<PhysicalObject> objects;
public final List<Platform> platforms; public final List<Platform> platforms;
public final List<Hazard> hazards; public final List<Hazard> hazards;
@@ -213,9 +216,41 @@ public final class LevelDefinition {
List<Hazard> hazards, List<Hazard> hazards,
List<Switch> switches, List<Switch> switches,
List<Door> doors 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.formatVersion = formatVersion;
this.physicsVersion = physicsVersion; this.physicsVersion = physicsVersion;
this.levelId = levelId;
this.worldId = worldId;
this.number = number; this.number = number;
this.title = title; this.title = title;
this.hint = hint; this.hint = hint;
@@ -228,6 +263,7 @@ public final class LevelDefinition {
this.goal = goal; this.goal = goal;
this.targetDeaths = targetDeaths; this.targetDeaths = targetDeaths;
this.targetSeconds = targetSeconds; this.targetSeconds = targetSeconds;
this.collectible = collectible;
this.objects = Collections.unmodifiableList(objects); this.objects = Collections.unmodifiableList(objects);
this.platforms = Collections.unmodifiableList(platforms); this.platforms = Collections.unmodifiableList(platforms);
this.hazards = Collections.unmodifiableList(hazards); this.hazards = Collections.unmodifiableList(hazards);
@@ -235,6 +271,10 @@ public final class LevelDefinition {
this.doors = Collections.unmodifiableList(doors); this.doors = Collections.unmodifiableList(doors);
} }
public static String defaultLevelId(int number) {
return "legacy.level-" + (number < 10 ? "0" : "") + number;
}
public boolean containsMechanic(HazardType type) { public boolean containsMechanic(HazardType type) {
for (Hazard hazard : hazards) { for (Hazard hazard : hazards) {
if (hazard.type == type) return true; if (hazard.type == type) return true;
@@ -41,11 +41,20 @@ public final class LevelJsonLoader {
List<LevelDefinition.Hazard> hazards = hazards(root, source); List<LevelDefinition.Hazard> hazards = hazards(root, source);
List<LevelDefinition.Switch> switches = switches(root, source); List<LevelDefinition.Switch> switches = switches(root, source);
List<LevelDefinition.Door> doors = doors(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( return LevelValidator.validate(new LevelDefinition(
integer(root, "formatVersion", source), integer(root, "formatVersion", source),
integer(root, "physicsVersion", source), integer(root, "physicsVersion", source),
integer(root, "number", source), levelId,
worldId,
number,
text(english, "title", source + ".copy.en"), text(english, "title", source + ".copy.en"),
text(english, "hint", source + ".copy.en"), text(english, "hint", source + ".copy.en"),
text(russian, "title", source + ".copy.ru"), text(russian, "title", source + ".copy.ru"),
@@ -57,6 +66,7 @@ public final class LevelJsonLoader {
box(required(root, "goal", source), source + ".goal"), box(required(root, "goal", source), source + ".goal"),
integer(targets, "deaths", source + ".targets"), integer(targets, "deaths", source + ".targets"),
integer(targets, "seconds", source + ".targets"), integer(targets, "seconds", source + ".targets"),
collectible,
objects, objects,
platforms, platforms,
hazards, hazards,
@@ -109,6 +109,13 @@ public final class LevelValidator {
for (int i = 0; i < level.doors.size(); i++) { for (int i = 0; i < level.doors.size(); i++) {
box(level.doors.get(i).box, level, source + ".doors[" + i + "].box"); 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.targetDeaths < 0) throw invalid(source + ".targets.deaths", "must not be negative");
if (level.targetSeconds <= 0) throw invalid(source + ".targets.seconds", "must be positive"); if (level.targetSeconds <= 0) throw invalid(source + ".targets.seconds", "must be positive");
Set<String> signalSources = new HashSet<>(); Set<String> signalSources = new HashSet<>();
@@ -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","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json","level-17.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":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,10 +60,12 @@ public final class LevelContentToolTest {
assertTrue(svg, svg.contains("viewBox=\"0 0 1920 1080\"")); assertTrue(svg, svg.contains("viewBox=\"0 0 1920 1080\""));
assertTrue(svg, svg.contains("data-kind=\"spawn\"")); assertTrue(svg, svg.contains("data-kind=\"spawn\""));
assertTrue(svg, svg.contains("data-kind=\"goal\"")); assertTrue(svg, svg.contains("data-kind=\"goal\""));
assertTrue(svg, svg.contains("data-kind=\"collectible\""));
assertTrue(svg, svg.contains("data-kind=\"object-COUNTERWEIGHT\"")); assertTrue(svg, svg.contains("data-kind=\"object-COUNTERWEIGHT\""));
String coordinates = Files.readString(first.resolve("catalog.tsv")); String coordinates = Files.readString(first.resolve("catalog.tsv"));
assertTrue(coordinates, coordinates.contains("1\tobject\t0\tCOUNTERWEIGHT")); assertTrue(coordinates, coordinates.contains("1\tobject\t0\tCOUNTERWEIGHT"));
assertTrue(coordinates, coordinates.contains("mass=3")); assertTrue(coordinates, coordinates.contains("mass=3"));
assertTrue(coordinates, coordinates.contains("1\tcollectible\t0\tCACHE"));
} }
private static void writeFixture(Path levels) throws Exception { private static void writeFixture(Path levels) throws Exception {
@@ -79,6 +81,7 @@ public final class LevelContentToolTest {
+ "\"world\":{\"width\":1920,\"height\":1080}," + "\"world\":{\"width\":1920,\"height\":1080},"
+ "\"spawn\":{\"x\":130,\"y\":760}," + "\"spawn\":{\"x\":130,\"y\":760},"
+ "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110}," + "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110},"
+ "\"collectible\":{\"x\":850,\"y\":650,\"width\":58,\"height\":58},"
+ "\"targets\":{\"deaths\":0,\"seconds\":30}," + "\"targets\":{\"deaths\":0,\"seconds\":30},"
+ "\"objects\":[{\"type\":\"COUNTERWEIGHT\"," + "\"objects\":[{\"type\":\"COUNTERWEIGHT\","
+ "\"box\":{\"x\":700,\"y\":780,\"width\":80,\"height\":100}," + "\"box\":{\"x\":700,\"y\":780,\"width\":80,\"height\":100},"