diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 91362f0..091e27a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -59,7 +59,7 @@ {"_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-e08","title":"EPIC-08 UI/UX","description":"Android controls, HUD, меню, выбор уровней и доступность. Разделы 16–17, 31–33, 45.","acceptance_criteria":"Игра полностью понятна и управляема на поддерживаемых экранах без внешних объяснений.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:59Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:29Z","metadata":{"stages":"0,3,6","primary_stage":6},"labels":["epic","primary-stage-6","stage-0","stage-3","stage-6","ui","ux"],"dependencies":[{"issue_id":"goon-game-e08","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e06","title":"EPIC-06 Camera","description":"Следящая камера, look-ahead, damping, bounds и focus targets. Раздел 18.","acceptance_criteria":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","notes":"Stage 5 camera acceptance complete: bounded follow/focus/shake, level bounds and reduced motion; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:03Z","closed_at":"2026-08-18T23:03:03Z","close_reason":"Camera foundation and production polish acceptance are complete.","metadata":{"stages":"0,5","primary_stage":5},"labels":["camera","epic","primary-stage-5","stage-0","stage-5"],"dependencies":[{"issue_id":"goon-game-e06","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"goon-game-e05","title":"EPIC-05 Puzzle Objects","description":"Кнопки, двери, платформы, весовые объекты и событийные сигналы. Разделы 11–14, 37.","acceptance_criteria":"Механизмы не связаны жёсткими ссылками и корректно работают с живым персонажем и телами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:53Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:15Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","primary-stage-6","puzzle","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e05","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"goon-game-e05","title":"EPIC-05 Puzzle Objects","description":"Кнопки, двери, платформы, весовые объекты и событийные сигналы. Разделы 11–14, 37.","acceptance_criteria":"Механизмы не связаны жёсткими ссылками и корректно работают с живым персонажем и телами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:53Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T04:38:26Z","closed_at":"2026-08-19T04:38:26Z","close_reason":"All puzzle-object children are complete: baseline switches/doors/platforms, event-driven PuzzleSystem and shared weighted physical objects. Player, corpse and authored objects now use data-driven uncoupled contracts with full regression evidence.","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","primary-stage-6","puzzle","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e05","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e04","title":"EPIC-04 Hazards","description":"Каталог опасностей и универсальный контракт источника смерти. Разделы 5, 10, 38.","acceptance_criteria":"Опасности data-driven, задокументированы и проходят Android/QA проверки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T02:28:53Z","closed_at":"2026-08-19T02:28:53Z","close_reason":"Both vertical-slice and production hazard catalogs are complete; all data-driven definitions, teaching levels and Android QA gates are green.","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","hazards","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e04","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:45Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e01","title":"EPIC-01 Character","description":"Персонаж, state machine, движение и platforming. Разделы 3, 17, 28, 36.","acceptance_criteria":"Персонаж соответствует утверждённому набору состояний и стабильно управляется на Android.","notes":"2026-08-19: baseline controller and production state-machine children are closed; exact Android 16 36/36 confirms stable controls and routes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:19:58Z","closed_at":"2026-08-19T00:19:58Z","close_reason":"Character controller and complete production animation-state contract are implemented and Android-verified.","metadata":{"stages":"0,6","primary_stage":6},"labels":["character","epic","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e01","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-milestone-alpha","title":"Milestone: Alpha","description":"Вся игра проходима от первого меню до финала; контент существует.","acceptance_criteria":"End-to-end прохождение завершено на release-like build.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:39Z","metadata":{"stages":8,"primary_stage":8},"labels":["alpha","milestone","primary-stage-8","stage-8"],"dependencies":[{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-crash-reporting","type":"blocks","created_at":"2026-08-18T00:44:28Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:44:27Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:57Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-milestone-production","type":"blocks","created_at":"2026-08-18T00:44:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} @@ -68,7 +68,7 @@ {"_type":"issue","id":"goon-game-modular-characters","title":"Модульные персонажи и косметика","description":"Body/helmet/face/weapon/cape/accessory, случайные имена и 30+ косметических элементов.","acceptance_criteria":"Новые жертвы визуально различаются, комбинации не ломают анимации.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2880,"created_at":"2026-08-17T21:40:21Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["art","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-modular-characters","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-challenges","title":"Challenges, collectibles и статистика","description":"Реализовать casualty/time/collectible цели и отображение трёх звёзд.","acceptance_criteria":"Расчёт покрыт unit-тестами и сохраняется по LevelID.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","progression","stage-6"],"dependencies":[{"issue_id":"goon-game-challenges","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","notes":"2026-08-19: claimed after corpse-state slice 19bb64e passed 102 JVM and exact Android 46/46. TDD scope: public IWeightedObject mass contract, JSON Box/Rock/Ball/IceBlock/Counterweight definitions, deterministic runtime/save/render/automation, pressure parity and one teaching level with solution bot.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T04:38:25Z","started_at":"2026-08-19T03:35:44Z","closed_at":"2026-08-19T04:38:25Z","close_reason":"Shared pressure-mass contract, five authored physical-object types, deterministic runtime/save v4/render/export, level 17 and production-input route accepted. Clean 108 app JVM + 2 format JVM; focused 8/8; exact unfiltered Android 16 matrix 48/48 in 2223.695s.","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","notes":"2026-08-19: claimed after the three Stage 6 foundation slices. Will proceed test-first per hazard with data definition, deterministic behavior, teaching content, solution graph and production-input route; crusher/fire precede the dependent crushed/burned corpse states.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T02:28:37Z","started_at":"2026-08-19T01:07:11Z","closed_at":"2026-08-19T02:28:37Z","close_reason":"Crusher, fire, projectile and cyclic explosion are data-driven, validated and shipped with teaching levels 11-14, solution graphs and 3x production-input routes; clean 99 JVM/lint/APK and unfiltered Android 43/43 green.","metadata":{"stages":6,"primary_stage":6},"labels":["hazards","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-hazards-full","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:10Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","notes":"2026-08-19: claimed after production hazards dc2ebcb passed 99 JVM and Android 43/43. Will add explicit crushed/burned/shattered and derived corpse behavior test-first, then one teaching route/solution graph per new gameplay-relevant state and full regression.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:49Z","started_at":"2026-08-19T02:31:25Z","closed_at":"2026-08-19T03:32:49Z","close_reason":"GDD corpse states complete: existing normal/pinned/frozen/conductive routes plus new burned/crushed contracts, save v3 migration, levels 15-16 and bots. Clean 102 JVM; exact Android 46/46 in 1842.248s. IsDestroyed is a lifecycle field; Shattered is not an approved GDD state.","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-hazards-full","type":"blocks","created_at":"2026-08-19T04:07:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e14","title":"EPIC-14 Analytics","description":"События прохождения, позиции смертей и crash reporting без лишних персональных данных. Раздел 47.","acceptance_criteria":"События и ошибки проверены в тестовом канале, схема версионирована.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:12Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:57Z","metadata":{"stages":"7,8,9,10","primary_stage":7},"labels":["analytics","epic","primary-stage-7","stage-10","stage-7","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e14","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:57Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index ac2ae81..7785b3b 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,14 @@ Project documents: - [`docs/STAGE5_PERFORMANCE_BUDGETS.md`](docs/STAGE5_PERFORMANCE_BUDGETS.md) — physics, replay, frame, memory, and complexity ceilings; - [`docs/STAGE5_QA_REPORT.md`](docs/STAGE5_QA_REPORT.md) — exact Vertical Slice candidate evidence; - [`docs/STAGE6_CORPSE_STATES.md`](docs/STAGE6_CORPSE_STATES.md) — burned/crushed body contracts and acceptance evidence; +- [`docs/STAGE6_WEIGHTED_OBJECTS.md`](docs/STAGE6_WEIGHTED_OBJECTS.md) — shared mass contract, physical objects, and level 17 evidence; - [`docs/STAGE5_HUMAN_PLAYTEST.md`](docs/STAGE5_HUMAN_PLAYTEST.md) — required external acceptance protocol; - [`docs/STAGE_REGISTRY.md`](docs/STAGE_REGISTRY.md) — mandatory Stage 0–10 ownership for every Bead; - [`docs/BEADS_WORKFLOW.md`](docs/BEADS_WORKFLOW.md) — local backlog structure and daily Beads commands. ## Playable content -The APK contains sixteen sequential levels: +The APK contains seventeen sequential levels: 1. movement and jumping; 2. bodies covering a spike lane; @@ -35,7 +36,8 @@ The APK contains sixteen sequential levels: 13. two state-driven projectile crossings; 14. explosion delivery to a remote pressure door; 15. a crushed body whose low collider becomes a required step; -16. a burned body that stays solid but no longer powers a pressure bridge. +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. 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. @@ -79,7 +81,7 @@ Previews are written to `level-format/build/level-previews`. Invalid geometry, versions, moving-platform destinations, switch parameters, or signal references fail the build with a JSON path diagnostic. -All sixteen levels have device-level acceptance bots. They drive the production +All seventeen levels have device-level acceptance bots. They drive the production `GameView` through the same movement and jump inputs as the touch controls and run each intended solution three times. Level 8 also has a dedicated recovery scenario for an expired timer on the far side of the pit. Stage 2 additionally @@ -117,11 +119,14 @@ deterministic character state machine, a bounded `CorpseManager`, and an event-driven `PuzzleSystem` with explicit signal and door transitions. The first mechanics slice adds data-driven crusher, fire, projectile and explosion hazards, teaching levels 11–14, dynamic level pagination and production-input routes. +Burned/crushed corpse states are taught in levels 15–16, and level 17 adds five +authored physical-object types over one shared pressure-mass contract. Scope and final evidence are in `docs/STAGE6_CHARACTER_STATES.md`, `docs/STAGE6_CORPSE_MANAGER.md`, and `docs/STAGE6_PUZZLE_SYSTEM.md`; the latest hazard evidence is in `docs/STAGE6_PRODUCTION_HAZARDS.md`, and the burned/crushed -contract is in `docs/STAGE6_CORPSE_STATES.md`. The latest exact gate is -`102 JVM / 46 Android` on the sixteen-level candidate. +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 +content-tool JVM / 48 Android` on the seventeen-level candidate. 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: diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java index 098f294..da55d52 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java @@ -37,6 +37,7 @@ public final class LevelPaginationTest { assertEquals(1, view.levelPageIndexForAutomation()); assertTrue(visible.contains("Steam Charge")); assertTrue(visible.contains("Ashen Counterweight")); + assertTrue(visible.contains("The Whole Weight")); assertFalse(visible.contains("The Volunteer")); assertTrue(visible.contains("Page 2 of 2")); }); diff --git a/app/src/androidTest/java/com/projectsacrifice/game/WeightedObjectIntegrationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/WeightedObjectIntegrationTest.java new file mode 100644 index 0000000..770921a --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/WeightedObjectIntegrationTest.java @@ -0,0 +1,42 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class WeightedObjectIntegrationTest { + @Test + public void productionInputPushesSharedMassOntoPlateAndSaveCapturesEveryObject() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.enableDeterministicAutomationClock(); + view.startLevelForAutomation(17); + + GameView.AutomationSnapshot initial = view.automationSnapshot(); + assertEquals(5, initial.physicalObjectCount()); + assertEquals("COUNTERWEIGHT", initial.firstPhysicalObjectType()); + assertEquals(3, initial.firstPhysicalObjectMass()); + assertFalse(initial.allDoorsOpen()); + + view.setAutomationInput(false, true, false); + view.advanceDeterministicAutomation(3_000L); + GameView.AutomationSnapshot pushed = view.automationSnapshot(); + assertTrue(pushed.firstPhysicalObjectX() >= 920f); + assertTrue(pushed.allDoorsOpen()); + + GameSessionSnapshot saved = view.sessionSnapshotForAutomation(); + assertEquals(5, saved.objects().size()); + assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, + saved.objects().get(0).type()); + }); + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/WeightedObjectPlaythroughTest.java b/app/src/androidTest/java/com/projectsacrifice/game/WeightedObjectPlaythroughTest.java new file mode 100644 index 0000000..a17231e --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/WeightedObjectPlaythroughTest.java @@ -0,0 +1,69 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.app.Instrumentation; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class WeightedObjectPlaythroughTest { + @Test + public void allFiveObjectTypesOpenTheMassNineGateThreeTimes() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + Driver driver = Driver.from(activity); + for (int run = 1; run <= 3; run++) { + LevelPlaytestBot.Result result = new LevelPlaytestBot().run( + driver, WeightedObjectScenarios.levelSeventeen(), 50_000L); + assertTrue("run=" + run + "; " + result.diagnostic(), result.completed()); + assertEquals("weighted route is deathless", 0, result.deaths()); + assertEquals("weighted route does not create bodies", 0, result.corpseCount()); + } + } + } + + private static final class Driver implements LevelPlaytestBot.Driver { + private final Instrumentation instrumentation; + private final GameView view; + + private Driver(Instrumentation instrumentation, GameView view) { + this.instrumentation = instrumentation; + this.view = view; + } + + static Driver from(ActivityScenario activity) { + GameView[] target = new GameView[1]; + activity.onActivity(current -> target[0] = current.gameViewForAutomation()); + return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]); + } + + @Override public void startLevel(int oneBasedLevelNumber) { + instrumentation.runOnMainSync(() -> view.startLevelForAutomation(oneBasedLevelNumber)); + } + + @Override public void setInput(boolean left, boolean right, boolean jump) { + instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump)); + } + + @Override public GameView.AutomationSnapshot snapshot() { + GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1]; + instrumentation.runOnMainSync(() -> snapshot[0] = view.automationSnapshot()); + return snapshot[0]; + } + + @Override public void waitForNextDecision(long millis) { + try { + Thread.sleep(Math.max(32L, millis)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("Weighted-object bot interrupted", interrupted); + } + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/WeightedObjectScenarios.java b/app/src/androidTest/java/com/projectsacrifice/game/WeightedObjectScenarios.java new file mode 100644 index 0000000..9130a96 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/WeightedObjectScenarios.java @@ -0,0 +1,52 @@ +package com.projectsacrifice.game; + +/** Production-input route for the weighted-object teaching puzzle. */ +final class WeightedObjectScenarios { + private WeightedObjectScenarios() {} + + static LevelPlaytestBot.Scenario levelSeventeen() { + return LevelPlaytestBot.Scenario.builder(17, "Level 17 - shared mass gate") + .step("Observe all authored weight types", 1_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.physicalObjectCount() == 5 + && snapshot.firstPhysicalObjectMass() == 3 + && "COUNTERWEIGHT".equals(snapshot.firstPhysicalObjectType()) + && !snapshot.allDoorsOpen()) + .step("Approach the counterweight", 2_000L, + LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(0, + LevelPlaytestBot.groundedAliveAtOrBeyond(520f))) + .step("Push the missing mass onto the plate", 3_000L, + LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(0, + snapshot -> snapshot.playerAlive() + && snapshot.firstPhysicalObjectX() >= 920f + && snapshot.allDoorsOpen())) + .step("Release input while object mass holds the gate", 1_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.allDoorsOpen() + && snapshot.firstPhysicalObjectX() >= 920f) + .step("Take off over the counterweight", 1_000L, + LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(0, + snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 700f)) + .step("Land beyond the counterweight", 2_500L, + LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(0, + snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerX() >= 1_080f)) + .step("Jump over the weighted row", 2_500L, + LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(0, + snapshot -> snapshot.playerAlive() + && snapshot.playerX() >= 1_650f)) + .step("Pass the mass-nine door and reach the relic", 5_000L, + LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(0, + GameView.AutomationSnapshot::completed)) + .build(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/CorpseManager.java b/app/src/main/java/com/projectsacrifice/game/CorpseManager.java index a0950f8..a631ea5 100644 --- a/app/src/main/java/com/projectsacrifice/game/CorpseManager.java +++ b/app/src/main/java/com/projectsacrifice/game/CorpseManager.java @@ -14,7 +14,7 @@ public final class CorpseManager { CRUSHED } - public static final class Body { + public static final class Body implements IWeightedObject { final long id; final String deathType; float x; @@ -54,6 +54,11 @@ public final class CorpseManager { public boolean activatesPressure() { return !burned; } public boolean grounded() { return grounded; } public int supportIndex() { return supportIndex; } + @Override public float weightedX() { return x; } + @Override public float weightedY() { return y; } + @Override public float weightedWidth() { return width; } + @Override public float weightedHeight() { return height; } + @Override public int pressureMass() { return 1; } public Body position(float x, float y) { this.x = x; diff --git a/app/src/main/java/com/projectsacrifice/game/GameSessionSnapshot.java b/app/src/main/java/com/projectsacrifice/game/GameSessionSnapshot.java index 5b89fb5..79c0647 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameSessionSnapshot.java +++ b/app/src/main/java/com/projectsacrifice/game/GameSessionSnapshot.java @@ -92,6 +92,41 @@ public final class GameSessionSnapshot { public String deathType() { return deathType; } } + public static final class ObjectData { + private final LevelDefinition.PhysicalObjectType type; + private final float x, y, width, height, velocityX, velocityY; + private final int mass; + private final boolean grounded; + private final int supportIndex; + + public ObjectData(LevelDefinition.PhysicalObjectType type, + float x, float y, float width, float height, + float velocityX, float velocityY, int mass, + boolean grounded, int supportIndex) { + this.type = type; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.velocityX = velocityX; + this.velocityY = velocityY; + this.mass = mass; + this.grounded = grounded; + this.supportIndex = supportIndex; + } + + public LevelDefinition.PhysicalObjectType type() { return type; } + public float x() { return x; } + public float y() { return y; } + public float width() { return width; } + public float height() { return height; } + public float velocityX() { return velocityX; } + public float velocityY() { return velocityY; } + public int mass() { return mass; } + public boolean grounded() { return grounded; } + public int supportIndex() { return supportIndex; } + } + public static final class Builder { private int saveVersion = VersionedSessionCodec.CURRENT_VERSION; private final int levelNumber; @@ -101,6 +136,7 @@ public final class GameSessionSnapshot { private PlayerData player = new PlayerData( 0f, 0f, 0f, 0f, true, false, 0f, 0f, -1, 1); private final ArrayList bodies = new ArrayList<>(); + private final ArrayList objects = new ArrayList<>(); private final ArrayList platformMotionTimes = new ArrayList<>(); private final LinkedHashMap toggleSignals = new LinkedHashMap<>(); private final LinkedHashMap timedSignals = new LinkedHashMap<>(); @@ -117,6 +153,7 @@ public final class GameSessionSnapshot { public Builder respawnSeconds(float value) { respawnSeconds = value; return this; } public Builder player(PlayerData value) { player = value; return this; } public Builder addBody(BodyData value) { bodies.add(value); return this; } + public Builder addObject(ObjectData value) { objects.add(value); return this; } public Builder platformMotionTimes(List values) { platformMotionTimes.clear(); if (values != null) platformMotionTimes.addAll(values); @@ -144,6 +181,7 @@ public final class GameSessionSnapshot { private final float respawnSeconds; private final PlayerData player; private final List bodies; + private final List objects; private final List platformMotionTimes; private final Map toggleSignals; private final Map timedSignals; @@ -157,6 +195,7 @@ public final class GameSessionSnapshot { respawnSeconds = builder.respawnSeconds; player = builder.player; bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies)); + objects = Collections.unmodifiableList(new ArrayList<>(builder.objects)); platformMotionTimes = Collections.unmodifiableList( new ArrayList<>(builder.platformMotionTimes)); toggleSignals = Collections.unmodifiableMap( @@ -174,6 +213,7 @@ public final class GameSessionSnapshot { public float respawnSeconds() { return respawnSeconds; } public PlayerData player() { return player; } public List bodies() { return bodies; } + public List objects() { return objects; } public List platformMotionTimes() { return platformMotionTimes; } public Map toggleSignals() { return toggleSignals; } public Map timedSignals() { return timedSignals; } diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 7e2675f..1565ca0 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -80,6 +80,11 @@ public final class GameView extends View implements Runnable { private final boolean firstCorpseActivatesPressure; private final float firstCorpseWidth; private final float firstCorpseHeight; + private int physicalObjectCount; + private float firstPhysicalObjectX = Float.NaN; + private float firstPhysicalObjectY = Float.NaN; + private int firstPhysicalObjectMass; + private String firstPhysicalObjectType = ""; AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, boolean visiblyPaused, boolean inputActive, int levelNumber, @@ -224,6 +229,40 @@ public final class GameView extends View implements Runnable { this.firstCorpseHeight = firstCorpseHeight; } + 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) { + 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); + this.physicalObjectCount = physicalObjectCount; + this.firstPhysicalObjectX = firstPhysicalObjectX; + this.firstPhysicalObjectY = firstPhysicalObjectY; + this.firstPhysicalObjectMass = firstPhysicalObjectMass; + this.firstPhysicalObjectType = firstPhysicalObjectType == null + ? "" : firstPhysicalObjectType; + } + public boolean completed() { return completed; } public boolean playerAlive() { return playerAlive; } public boolean allDoorsOpen() { return allDoorsOpen; } @@ -255,9 +294,14 @@ public final class GameView extends View implements Runnable { } public float firstCorpseWidth() { return firstCorpseWidth; } public float firstCorpseHeight() { return firstCorpseHeight; } + public int physicalObjectCount() { return physicalObjectCount; } + public float firstPhysicalObjectX() { return firstPhysicalObjectX; } + public float firstPhysicalObjectY() { return firstPhysicalObjectY; } + public int firstPhysicalObjectMass() { return firstPhysicalObjectMass; } + public String firstPhysicalObjectType() { return firstPhysicalObjectType; } } - private static final class Player { + private static final class Player implements IWeightedObject { float x, y, vx, vy; boolean alive; boolean onGround; @@ -265,6 +309,13 @@ public final class GameView extends View implements Runnable { float jumpBuffer; int supportIndex = -1; int facing = 1; + + @Override public float weightedX() { return x; } + @Override public float weightedY() { return y; } + @Override public float weightedWidth() { return PLAYER_WIDTH; } + @Override public float weightedHeight() { return PLAYER_HEIGHT; } + @Override public int pressureMass() { return 1; } + @Override public boolean activatesPressure() { return alive; } } private static final class PlatformState { @@ -302,6 +353,7 @@ public final class GameView extends View implements Runnable { new CharacterStateMachine(PHYSICS); private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES); private final List corpses = corpseManager.bodies(); + private final ArrayList physicalObjects = new ArrayList<>(); private final ArrayList platformStates = new ArrayList<>(); private final ArrayList hazardRuntimes = new ArrayList<>(); private final Map previousSwitchContact = new HashMap<>(); @@ -470,6 +522,7 @@ public final class GameView extends View implements Runnable { pointers.clear(); deriveControls(); corpseManager.reset(); + physicalObjects.clear(); platformStates.clear(); hazardRuntimes.clear(); previousSwitchContact.clear(); @@ -478,6 +531,9 @@ public final class GameView extends View implements Runnable { configureAccessibilitySignals(); invalidateHudTextCache(); for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); + for (LevelDefinition.PhysicalObject item : level.objects) { + physicalObjects.add(new PhysicalObjectState(item)); + } for (LevelDefinition.Hazard item : level.hazards) { hazardRuntimes.add(new HazardRuntime(item)); } @@ -516,6 +572,7 @@ public final class GameView extends View implements Runnable { int substeps = 2; float step = dt / substeps; for (int i = 0; i < substeps; i++) { + updatePhysicalObjects(step); updateCorpses(step); if (player.alive) updatePlayer(step); } @@ -633,10 +690,12 @@ public final class GameView extends View implements Runnable { private boolean switchContact(LevelDefinition.Switch item) { int mass = 0; - if (player.alive && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) mass++; + mass += PressureMassResolver.contribution(item.box, player); for (Body corpse : corpses) { - if (!corpse.activatesPressure()) continue; - if (intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) mass++; + mass += PressureMassResolver.contribution(item.box, corpse); + } + for (PhysicalObjectState object : physicalObjects) { + mass += PressureMassResolver.contribution(item.box, object); } return mass >= item.requiredMass; } @@ -675,6 +734,13 @@ public final class GameView extends View implements Runnable { corpse.y += support.y - support.previousY; } } + for (PhysicalObjectState object : physicalObjects) { + if (object.supportIndex >= 0 && object.supportIndex < platformStates.size()) { + PlatformState support = platformStates.get(object.supportIndex); + object.x += support.x - support.previousX; + object.y += support.y - support.previousY; + } + } } private void updatePlayer(float dt) { @@ -712,6 +778,19 @@ public final class GameView extends View implements Runnable { if (amount == 0f) return; player.x += amount; RectF current = playerRect(); + for (PhysicalObjectState object : physicalObjects) { + RectF solid = physicalObjectRect(object); + if (!RectF.intersects(current, solid)) continue; + boolean moved = movePhysicalObjectHorizontal(object, amount); + solid = physicalObjectRect(object); + current = playerRect(); + if (!moved || RectF.intersects(current, solid)) { + if (amount > 0f) player.x = solid.left - PLAYER_WIDTH; + else player.x = solid.right; + player.vx = 0f; + current = playerRect(); + } + } for (RectF solid : solidRects(true)) { if (!RectF.intersects(current, solid)) continue; if (amount > 0f) player.x = solid.left - PLAYER_WIDTH; @@ -765,6 +844,107 @@ public final class GameView extends View implements Runnable { } current = playerRect(); } + for (PhysicalObjectState object : physicalObjects) { + RectF solid = physicalObjectRect(object); + if (!RectF.intersects(current, solid)) continue; + if (amount >= 0f) { + player.y = solid.top - PLAYER_HEIGHT; + player.vy = 0f; + player.onGround = true; + } else { + player.y = solid.bottom; + player.vy = 0f; + } + current = playerRect(); + } + } + + private void updatePhysicalObjects(float dt) { + for (PhysicalObjectState object : physicalObjects) { + object.vy += GRAVITY * dt; + object.grounded = false; + if (object.vx != 0f) movePhysicalObjectHorizontal(object, object.vx * dt); + object.y += object.vy * dt; + resolvePhysicalObjectVertical(object); + if (object.grounded) object.vx *= (float) Math.pow(0.04, dt); + if (Math.abs(object.vx) < 3f) object.vx = 0f; + } + } + + private boolean movePhysicalObjectHorizontal(PhysicalObjectState object, float amount) { + if (amount == 0f) return true; + float before = object.x; + object.x += amount; + RectF current = physicalObjectRect(object); + for (PlatformState platform : platformStates) { + RectF solid = platform.rect(); + if (!RectF.intersects(current, solid)) continue; + object.x = amount > 0f ? solid.left - object.width : solid.right; + current = physicalObjectRect(object); + } + for (LevelDefinition.Door door : level.doors) { + if (doorOpen(door)) continue; + RectF solid = rect(door.box); + if (!RectF.intersects(current, solid)) continue; + object.x = amount > 0f ? solid.left - object.width : solid.right; + current = physicalObjectRect(object); + } + for (Body corpse : corpses) { + RectF solid = corpseRect(corpse); + if (!RectF.intersects(current, solid)) continue; + object.x = amount > 0f ? solid.left - object.width : solid.right; + current = physicalObjectRect(object); + } + for (PhysicalObjectState other : physicalObjects) { + if (other == object) continue; + RectF solid = physicalObjectRect(other); + if (!RectF.intersects(current, solid)) continue; + object.x = amount > 0f ? solid.left - object.width : solid.right; + current = physicalObjectRect(object); + } + object.x = GameMath.clamp(object.x, 0f, level.worldWidth - object.width); + if (Math.abs(object.x - before) + 0.01f < Math.abs(amount)) object.vx = 0f; + return Math.abs(object.x - before) + 0.01f >= Math.abs(amount); + } + + private void resolvePhysicalObjectVertical(PhysicalObjectState object) { + object.supportIndex = -1; + RectF current = physicalObjectRect(object); + for (int i = 0; i < platformStates.size(); i++) { + RectF solid = platformStates.get(i).rect(); + if (!RectF.intersects(current, solid)) continue; + if (object.vy >= 0f) { + object.y = solid.top - object.height; + object.vy = 0f; + object.grounded = true; + object.supportIndex = i; + } else { + object.y = solid.bottom; + object.vy = 0f; + } + current = physicalObjectRect(object); + } + for (LevelDefinition.Door door : level.doors) { + if (doorOpen(door)) continue; + RectF solid = rect(door.box); + if (!RectF.intersects(current, solid)) continue; + if (object.vy >= 0f) { + object.y = solid.top - object.height; + object.grounded = true; + } else object.y = solid.bottom; + object.vy = 0f; + current = physicalObjectRect(object); + } + for (Body corpse : corpses) { + RectF solid = corpseRect(corpse); + if (!RectF.intersects(current, solid)) continue; + if (object.vy >= 0f) { + object.y = solid.top - object.height; + object.grounded = true; + } else object.y = solid.bottom; + object.vy = 0f; + current = physicalObjectRect(object); + } } private void updateCorpses(float dt) { @@ -848,6 +1028,9 @@ public final class GameView extends View implements Runnable { for (PlatformState platform : platformStates) result.add(platform.rect()); for (LevelDefinition.Door door : level.doors) if (!doorOpen(door)) result.add(rect(door.box)); if (includeCorpses) for (Body corpse : corpses) result.add(corpseRect(corpse)); + for (PhysicalObjectState object : physicalObjects) { + result.add(physicalObjectRect(object)); + } return result; } @@ -978,6 +1161,11 @@ public final class GameView extends View implements Runnable { corpse.x + corpse.width, corpse.y + corpse.height); } + private static RectF physicalObjectRect(PhysicalObjectState object) { + return new RectF(object.x, object.y, + object.x + object.width, object.y + object.height); + } + private static RectF rect(LevelDefinition.Box box) { return new RectF(box.x, box.y, box.right(), box.bottom()); } @@ -1016,13 +1204,14 @@ public final class GameView extends View implements Runnable { private void recordPerformanceComplexity() { if (level == null) return; int bodies = corpses.size(); - int solids = platformStates.size() + level.doors.size() + bodies; - int actors = 1 + bodies; + int objects = physicalObjects.size(); + int solids = platformStates.size() + level.doors.size() + bodies + objects; + int actors = 1 + bodies + objects; int collisionChecks = solids * actors * 2 + level.hazards.size() + level.switches.size() * actors; int drawPrimitives = 180 + platformStates.size() * 8 + level.switches.size() * 9 + level.hazards.size() * 20 - + level.doors.size() * 12 + bodies * 28; + + level.doors.size() * 12 + bodies * 28 + objects * 8; Runtime runtime = Runtime.getRuntime(); long usedMemory = runtime.totalMemory() - runtime.freeMemory(); performanceMonitor.recordComplexity(bodies, collisionChecks, @@ -1039,6 +1228,7 @@ public final class GameView extends View implements Runnable { drawHazards(canvas); drawDoors(canvas); drawGoal(canvas); + for (PhysicalObjectState object : physicalObjects) drawPhysicalObject(canvas, object); for (Body corpse : corpses) drawCorpse(canvas, corpse); VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number); if (player.alive) { @@ -1398,6 +1588,50 @@ public final class GameView extends View implements Runnable { } } + private void drawPhysicalObject(Canvas canvas, PhysicalObjectState object) { + RectF box = physicalObjectRect(object); + boolean contrast = accessibilitySettings.highContrastEnabled(); + switch (object.type) { + case BOX: + paint.setColor(contrast ? Color.WHITE : Color.rgb(151, 103, 55)); + canvas.drawRoundRect(box, 8f, 8f, paint); + stroke.setColor(contrast ? Color.BLACK : Color.rgb(225, 176, 91)); + stroke.setStrokeWidth(5f); + canvas.drawLine(box.left + 8f, box.top + 8f, + box.right - 8f, box.bottom - 8f, stroke); + canvas.drawLine(box.right - 8f, box.top + 8f, + box.left + 8f, box.bottom - 8f, stroke); + break; + case ROCK: + paint.setColor(contrast ? Color.WHITE : Color.rgb(109, 112, 123)); + canvas.drawOval(box, paint); + break; + case BALL: + paint.setColor(contrast ? Color.WHITE : Color.rgb(202, 126, 56)); + canvas.drawCircle(box.centerX(), box.centerY(), + Math.min(box.width(), box.height()) * 0.5f, paint); + break; + case ICE_BLOCK: + paint.setColor(contrast ? Color.WHITE : Color.rgb(106, 209, 235)); + canvas.drawRoundRect(box, 12f, 12f, paint); + stroke.setColor(contrast ? Color.BLACK : Color.rgb(221, 252, 255)); + stroke.setStrokeWidth(5f); + canvas.drawLine(box.left + 10f, box.bottom - 12f, + box.right - 12f, box.top + 10f, stroke); + break; + case COUNTERWEIGHT: + paint.setColor(contrast ? Color.WHITE : Color.rgb(89, 79, 66)); + canvas.drawRoundRect(box, 14f, 14f, paint); + stroke.setColor(contrast ? Color.BLACK : Color.rgb(237, 190, 77)); + stroke.setStrokeWidth(7f); + canvas.drawLine(box.centerX(), box.top + 10f, + box.centerX(), box.bottom - 10f, stroke); + break; + } + drawWorldLabel(canvas, object.type.name() + " W" + object.mass, + box.centerX(), box.top - 16f); + } + private void drawHud(Canvas canvas) { ensureHudTextCache(); paint.setColor(Color.argb(178, 15, 20, 34)); @@ -2575,6 +2809,12 @@ public final class GameView extends View implements Runnable { corpse.supportIndex, corpse.deathType())); } + for (PhysicalObjectState object : physicalObjects) { + builder.addObject(new GameSessionSnapshot.ObjectData( + object.type, object.x, object.y, object.width, object.height, + object.vx, object.vy, object.mass, object.grounded, + object.supportIndex)); + } ArrayList motionTimes = new ArrayList<>(platformStates.size()); for (PlatformState state : platformStates) motionTimes.add(state.motionTime); builder.platformMotionTimes(motionTimes); @@ -2597,6 +2837,8 @@ public final class GameView extends View implements Runnable { LevelDefinition definition = LevelCatalog.get(index); if (snapshot.platformMotionTimes().size() != definition.platforms.size() || snapshot.bodies().size() > MAX_CORPSES) return false; + if (!snapshot.objects().isEmpty() + && snapshot.objects().size() != definition.objects.size()) return false; if (!validSupport(snapshot.player().supportIndex(), definition.platforms.size())) { return false; } @@ -2608,6 +2850,13 @@ public final class GameView extends View implements Runnable { return false; } } + for (int i = 0; i < snapshot.objects().size(); i++) { + GameSessionSnapshot.ObjectData object = snapshot.objects().get(i); + LevelDefinition.PhysicalObject authored = definition.objects.get(i); + if (!validSupport(object.supportIndex(), definition.platforms.size()) + || object.type() != authored.type || object.mass() != authored.mass + || object.width() <= 0f || object.height() <= 0f) return false; + } startLevel(index); levelElapsed = snapshot.elapsedSeconds(); @@ -2664,6 +2913,12 @@ public final class GameView extends View implements Runnable { corpseManager.transform(corpse, CorpseManager.Transformation.BURNED); } } + if (!snapshot.objects().isEmpty()) { + physicalObjects.clear(); + for (GameSessionSnapshot.ObjectData savedObject : snapshot.objects()) { + physicalObjects.add(new PhysicalObjectState(savedObject)); + } + } updateSignals(0f, false); leftPressed = false; @@ -2721,6 +2976,8 @@ public final class GameView extends View implements Runnable { firstHazardY = firstHazardBox.y; } Body firstCorpse = corpses.isEmpty() ? null : corpses.get(0); + PhysicalObjectState firstObject = physicalObjects.isEmpty() + ? null : physicalObjects.get(0); return new AutomationSnapshot( screen == Screen.COMPLETE, player.alive, @@ -2750,7 +3007,12 @@ public final class GameView extends View implements Runnable { firstCorpse != null && firstCorpse.crushed(), firstCorpse != null && firstCorpse.activatesPressure(), firstCorpse == null ? Float.NaN : firstCorpse.width(), - firstCorpse == null ? Float.NaN : firstCorpse.height() + firstCorpse == null ? Float.NaN : firstCorpse.height(), + physicalObjects.size(), + firstObject == null ? Float.NaN : firstObject.x, + firstObject == null ? Float.NaN : firstObject.y, + firstObject == null ? 0 : firstObject.mass, + firstObject == null ? "" : firstObject.type.name() ); } diff --git a/app/src/main/java/com/projectsacrifice/game/IWeightedObject.java b/app/src/main/java/com/projectsacrifice/game/IWeightedObject.java new file mode 100644 index 0000000..c29eb39 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/IWeightedObject.java @@ -0,0 +1,11 @@ +package com.projectsacrifice.game; + +/** Shared pressure-plate contract for living, retained, and authored objects. */ +public interface IWeightedObject { + float weightedX(); + float weightedY(); + float weightedWidth(); + float weightedHeight(); + int pressureMass(); + boolean activatesPressure(); +} diff --git a/app/src/main/java/com/projectsacrifice/game/PhysicalObjectState.java b/app/src/main/java/com/projectsacrifice/game/PhysicalObjectState.java new file mode 100644 index 0000000..8122037 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/PhysicalObjectState.java @@ -0,0 +1,61 @@ +package com.projectsacrifice.game; + +/** Mutable deterministic runtime for one authored weighted object. */ +public final class PhysicalObjectState implements IWeightedObject { + final LevelDefinition.PhysicalObjectType type; + final int mass; + float x, y, width, height, vx, vy; + boolean grounded; + int supportIndex = -1; + + public PhysicalObjectState(LevelDefinition.PhysicalObject definition) { + if (definition == null) throw new IllegalArgumentException("definition is required"); + type = definition.type; + mass = definition.mass; + x = definition.box.x; + y = definition.box.y; + width = definition.box.width; + height = definition.box.height; + } + + PhysicalObjectState(GameSessionSnapshot.ObjectData saved) { + type = saved.type(); + mass = saved.mass(); + x = saved.x(); + y = saved.y(); + width = saved.width(); + height = saved.height(); + vx = saved.velocityX(); + vy = saved.velocityY(); + grounded = saved.grounded(); + supportIndex = saved.supportIndex(); + } + + public PhysicalObjectState position(float valueX, float valueY) { + x = valueX; + y = valueY; + return this; + } + + public PhysicalObjectState velocity(float valueX, float valueY) { + vx = valueX; + vy = valueY; + return this; + } + + public PhysicalObjectState grounded(boolean value) { grounded = value; return this; } + public PhysicalObjectState supportIndex(int value) { supportIndex = value; return this; } + + public LevelDefinition.PhysicalObjectType type() { return type; } + public float velocityX() { return vx; } + public float velocityY() { return vy; } + public boolean grounded() { return grounded; } + public int supportIndex() { return supportIndex; } + + @Override public float weightedX() { return x; } + @Override public float weightedY() { return y; } + @Override public float weightedWidth() { return width; } + @Override public float weightedHeight() { return height; } + @Override public int pressureMass() { return mass; } + @Override public boolean activatesPressure() { return true; } +} diff --git a/app/src/main/java/com/projectsacrifice/game/PressureMassResolver.java b/app/src/main/java/com/projectsacrifice/game/PressureMassResolver.java new file mode 100644 index 0000000..4595aed --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/PressureMassResolver.java @@ -0,0 +1,31 @@ +package com.projectsacrifice.game; + +/** Pure mass aggregation; it deliberately has no knowledge of object kinds. */ +public final class PressureMassResolver { + private PressureMassResolver() {} + + public static boolean active(LevelDefinition.Box plate, int requiredMass, + Iterable objects) { + int mass = 0; + if (plate == null || objects == null || requiredMass <= 0) return false; + for (IWeightedObject object : objects) { + if (object == null || !object.activatesPressure()) continue; + if (!intersects(object, plate)) continue; + mass += Math.max(0, object.pressureMass()); + if (mass >= requiredMass) return true; + } + return false; + } + + public static int contribution(LevelDefinition.Box plate, IWeightedObject object) { + if (plate == null || object == null || !object.activatesPressure()) return 0; + return intersects(object, plate) ? Math.max(0, object.pressureMass()) : 0; + } + + private static boolean intersects(IWeightedObject object, LevelDefinition.Box box) { + return object.weightedX() < box.right() + && object.weightedX() + object.weightedWidth() > box.x + && object.weightedY() < box.bottom() + && object.weightedY() + object.weightedHeight() > box.y; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java b/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java index f5c5e0c..13d4d01 100644 --- a/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java +++ b/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java @@ -12,9 +12,10 @@ import java.util.Map; public final class VersionedSessionCodec { private static final int MAGIC = 0x47534e50; // GSNP private static final int MAX_BODIES = 16; + private static final int MAX_OBJECTS = 64; private static final int MAX_PLATFORMS = 64; private static final int MAX_MAP_ENTRIES = 128; - public static final int CURRENT_VERSION = 3; + public static final int CURRENT_VERSION = 4; public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT } @@ -45,6 +46,10 @@ public final class VersionedSessionCodec { return encodeWithVersion(snapshot, 2); } + static byte[] encodeLegacyV3(GameSessionSnapshot snapshot) { + return encodeWithVersion(snapshot, 3); + } + static byte[] encodeVersionForTest(int version) { try { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); @@ -80,6 +85,10 @@ public final class VersionedSessionCodec { int bodyCount = checkedCount(input.readInt(), MAX_BODIES); for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input, version)); + if (version >= 4) { + int objectCount = checkedCount(input.readInt(), MAX_OBJECTS); + for (int i = 0; i < objectCount; i++) builder.addObject(readObject(input)); + } int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS); ArrayList motionTimes = new ArrayList<>(platformCount); @@ -115,6 +124,12 @@ public final class VersionedSessionCodec { for (GameSessionSnapshot.BodyData body : snapshot.bodies()) { writeBody(output, body, version); } + if (version >= 4) { + output.writeInt(snapshot.objects().size()); + for (GameSessionSnapshot.ObjectData object : snapshot.objects()) { + writeObject(output, object); + } + } output.writeInt(snapshot.platformMotionTimes().size()); for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time); writeBooleanMap(output, snapshot.toggleSignals()); @@ -194,6 +209,39 @@ public final class VersionedSessionCodec { crushed, burned, support, deathType); } + private static void writeObject(DataOutputStream output, + GameSessionSnapshot.ObjectData object) throws IOException { + output.writeUTF(object.type().name()); + output.writeFloat(object.x()); + output.writeFloat(object.y()); + output.writeFloat(object.width()); + output.writeFloat(object.height()); + output.writeFloat(object.velocityX()); + output.writeFloat(object.velocityY()); + output.writeInt(object.mass()); + output.writeBoolean(object.grounded()); + output.writeInt(object.supportIndex()); + } + + private static GameSessionSnapshot.ObjectData readObject(DataInputStream input) + throws IOException { + String typeName = input.readUTF(); + LevelDefinition.PhysicalObjectType type = + LevelDefinition.PhysicalObjectType.valueOf(typeName); + float x = finite(input.readFloat()); + float y = finite(input.readFloat()); + float width = finite(input.readFloat()); + float height = finite(input.readFloat()); + float velocityX = finite(input.readFloat()); + float velocityY = finite(input.readFloat()); + int mass = input.readInt(); + if (mass <= 0) throw new IllegalArgumentException("Object mass must be positive"); + boolean grounded = input.readBoolean(); + int supportIndex = input.readInt(); + return new GameSessionSnapshot.ObjectData(type, x, y, width, height, + velocityX, velocityY, mass, grounded, supportIndex); + } + private static void writeBooleanMap(DataOutputStream output, Map values) throws IOException { output.writeInt(values.size()); diff --git a/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java b/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java index d2ef57e..14353f0 100644 --- a/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java +++ b/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java @@ -80,6 +80,9 @@ public final class CorpseManagerTest { assertFalse(body.crushed()); assertFalse(body.activatesPressure()); assertTrue(manager.bodies().contains(body)); + assertTrue(body instanceof IWeightedObject); + assertEquals(1, body.pressureMass()); + assertEquals(body.x(), body.weightedX(), 0f); } @Test diff --git a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java index ce894c7..7f39e97 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue; import org.junit.Test; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -14,7 +15,7 @@ public final class LevelCatalogTest { @Test public void productionCatalogContainsHazardsAndTwoCorpseStateTeachingLevels() { List levels = LevelCatalog.all(); - assertEquals(16, levels.size()); + assertEquals(17, levels.size()); Set titles = new HashSet<>(); for (int i = 0; i < levels.size(); i++) { LevelDefinition level = levels.get(i); @@ -29,6 +30,16 @@ public final class LevelCatalogTest { assertTrue(levels.get(14).containsMechanic(LevelDefinition.HazardType.CRUSHER)); assertTrue(levels.get(15).containsMechanic(LevelDefinition.HazardType.FIRE)); assertFalse(levels.get(15).platforms.get(1).controlId.isEmpty()); + LevelDefinition weighted = levels.get(16); + assertEquals(5, weighted.objects.size()); + Set objectTypes = new HashSet<>(); + for (LevelDefinition.PhysicalObject object : weighted.objects) { + objectTypes.add(object.type); + } + assertEquals(new HashSet<>(Arrays.asList(LevelDefinition.PhysicalObjectType.values())), + objectTypes); + assertEquals(9, weighted.switches.get(0).requiredMass); + assertFalse(weighted.doors.isEmpty()); } @Test diff --git a/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java b/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java index a41196a..4654524 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java @@ -214,6 +214,43 @@ public final class LevelJsonLoaderTest { assertEquals("explosion-blast", explosion.audioCue); } + @Test + public void allApprovedWeightedObjectTypesLoadThroughOneMassContract() { + String objects = "\"objects\":[" + + "{\"type\":\"BOX\",\"box\":{\"x\":300,\"y\":800,\"width\":80,\"height\":80},\"mass\":1}," + + "{\"type\":\"ROCK\",\"box\":{\"x\":400,\"y\":820,\"width\":60,\"height\":60},\"mass\":2}," + + "{\"type\":\"BALL\",\"box\":{\"x\":500,\"y\":820,\"width\":60,\"height\":60},\"mass\":1}," + + "{\"type\":\"ICE_BLOCK\",\"box\":{\"x\":600,\"y\":800,\"width\":80,\"height\":80},\"mass\":2}," + + "{\"type\":\"COUNTERWEIGHT\",\"box\":{\"x\":700,\"y\":780,\"width\":80,\"height\":100},\"mass\":3}],"; + String json = validLevelJson(1).replace("\"platforms\":", objects + "\"platforms\":"); + + LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "weighted.json"); + + assertEquals(5, level.objects.size()); + assertEquals(LevelDefinition.PhysicalObjectType.BOX, level.objects.get(0).type); + assertEquals(LevelDefinition.PhysicalObjectType.ROCK, level.objects.get(1).type); + assertEquals(LevelDefinition.PhysicalObjectType.BALL, level.objects.get(2).type); + assertEquals(LevelDefinition.PhysicalObjectType.ICE_BLOCK, level.objects.get(3).type); + assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, level.objects.get(4).type); + assertEquals(3, level.objects.get(4).mass); + } + + @Test + public void weightedObjectRejectsNonPositiveMassWithItsJsonPath() { + String object = "\"objects\":[{\"type\":\"BOX\"," + + "\"box\":{\"x\":300,\"y\":800,\"width\":80,\"height\":80},\"mass\":0}],"; + String json = validLevelJson(1).replace("\"platforms\":", object + "\"platforms\":"); + + try { + LevelJsonLoader.load(new StringReader(json), "massless.json"); + fail("A pressure object with no mass must fail closed"); + } catch (IllegalArgumentException error) { + assertTrue(error.getMessage(), error.getMessage().contains( + "massless.json.objects[0].mass")); + assertTrue(error.getMessage(), error.getMessage().contains("must be positive")); + } + } + static String validLevelJson(int formatVersion) { return "{" + "\"formatVersion\":" + formatVersion + "," diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java index ed9df58..e74a01f 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java @@ -8,8 +8,8 @@ import org.junit.Test; public final class LevelPageTest { @Test - public void sixteenLevelsUseTwoBoundedTenCardPages() { - LevelPage page = new LevelPage(16, 10); + public void seventeenLevelsUseTwoBoundedTenCardPages() { + LevelPage page = new LevelPage(17, 10); assertEquals(2, page.pageCount()); assertEquals(0, page.firstIndex()); @@ -19,7 +19,7 @@ public final class LevelPageTest { page.next(); assertEquals(10, page.firstIndex()); - assertEquals(16, page.endExclusive()); + assertEquals(17, page.endExclusive()); assertTrue(page.hasPrevious()); assertFalse(page.hasNext()); @@ -29,5 +29,7 @@ public final class LevelPageTest { assertEquals(0, page.pageIndex()); page.selectIndex(15); assertEquals(1, page.pageIndex()); + page.selectIndex(16); + assertEquals(1, page.pageIndex()); } } diff --git a/app/src/test/java/com/projectsacrifice/game/PhysicalObjectStateTest.java b/app/src/test/java/com/projectsacrifice/game/PhysicalObjectStateTest.java new file mode 100644 index 0000000..6a1f603 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/PhysicalObjectStateTest.java @@ -0,0 +1,30 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public final class PhysicalObjectStateTest { + @Test + public void authoredObjectPublishesStableMassGeometryAndRuntimeState() { + LevelDefinition.PhysicalObject definition = new LevelDefinition.PhysicalObject( + LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, + new LevelDefinition.Box(420f, 760f, 80f, 120f), 3); + + PhysicalObjectState state = new PhysicalObjectState(definition); + state.velocity(120f, -30f).grounded(true).supportIndex(2); + + assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, state.type()); + assertEquals(420f, state.weightedX(), 0f); + assertEquals(760f, state.weightedY(), 0f); + assertEquals(80f, state.weightedWidth(), 0f); + assertEquals(120f, state.weightedHeight(), 0f); + assertEquals(3, state.pressureMass()); + assertTrue(state.activatesPressure()); + assertEquals(120f, state.velocityX(), 0f); + assertEquals(-30f, state.velocityY(), 0f); + assertTrue(state.grounded()); + assertEquals(2, state.supportIndex()); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java b/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java index 52b9a38..11fa3d6 100644 --- a/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java +++ b/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java @@ -29,6 +29,10 @@ public final class VersionedSessionCodecTest { assertTrue(restored.bodies().get(1).conductive()); assertTrue(restored.bodies().get(0).crushed()); assertTrue(restored.bodies().get(1).burned()); + assertEquals(2, restored.objects().size()); + assertEquals(LevelDefinition.PhysicalObjectType.BOX, + restored.objects().get(0).type()); + assertEquals(3, restored.objects().get(1).mass()); assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes()); assertTrue(restored.toggleSignals().get("gate")); assertEquals(2.25f, restored.timedSignals().get("clock"), 0f); @@ -58,6 +62,18 @@ public final class VersionedSessionCodecTest { assertTrue(decoded.snapshot().previousSwitchContacts().get("gate@420.0")); assertFalse(decoded.snapshot().bodies().get(0).crushed()); assertFalse(decoded.snapshot().bodies().get(1).burned()); + assertTrue(decoded.snapshot().objects().isEmpty()); + } + + @Test + public void legacyV3MigratesWithSafeEmptyPhysicalObjectRuntime() { + VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode( + VersionedSessionCodec.encodeLegacyV3(sampleSnapshot())); + + assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status()); + assertTrue(decoded.snapshot().bodies().get(0).crushed()); + assertTrue(decoded.snapshot().bodies().get(1).burned()); + assertTrue(decoded.snapshot().objects().isEmpty()); } @Test @@ -105,6 +121,12 @@ public final class VersionedSessionCodecTest { 720f, 826f, 72f, 54f, 0f, 0f, false, true, true, true, false, true, -1, "FIRE")) + .addObject(new GameSessionSnapshot.ObjectData( + LevelDefinition.PhysicalObjectType.BOX, + 300f, 800f, 80f, 80f, 20f, 0f, 1, true, -1)) + .addObject(new GameSessionSnapshot.ObjectData( + LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, + 700f, 780f, 80f, 100f, 0f, 0f, 3, true, 1)) .platformMotionTimes(Arrays.asList(1.25f, 3.5f)) .toggleSignal("gate", true) .timedSignal("clock", 2.25f) diff --git a/app/src/test/java/com/projectsacrifice/game/WeightedPressureResolverTest.java b/app/src/test/java/com/projectsacrifice/game/WeightedPressureResolverTest.java new file mode 100644 index 0000000..2b6b1ae --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/WeightedPressureResolverTest.java @@ -0,0 +1,45 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; + +import org.junit.Test; + +public final class WeightedPressureResolverTest { + @Test + public void plateUsesOneMassContractForPlayerCorpseAndPhysicalObject() { + LevelDefinition.Box plate = new LevelDefinition.Box(100f, 200f, 180f, 20f); + IWeightedObject player = item(110f, 150f, 62f, 100f, 1, true); + IWeightedObject corpse = item(170f, 166f, 72f, 54f, 1, true); + IWeightedObject counterweight = item(230f, 140f, 70f, 80f, 3, true); + + assertFalse(PressureMassResolver.active( + plate, 6, Arrays.asList(player, corpse, counterweight))); + assertTrue(PressureMassResolver.active( + plate, 5, Arrays.asList(player, corpse, counterweight))); + } + + @Test + public void inactiveAndNonIntersectingObjectsNeverContributeMass() { + LevelDefinition.Box plate = new LevelDefinition.Box(100f, 200f, 180f, 20f); + IWeightedObject burnedCorpse = item(120f, 166f, 72f, 54f, 5, false); + IWeightedObject distantRock = item(500f, 160f, 60f, 60f, 5, true); + + assertFalse(PressureMassResolver.active( + plate, 1, Arrays.asList(burnedCorpse, distantRock))); + } + + private static IWeightedObject item(float x, float y, float width, float height, + int mass, boolean active) { + return new IWeightedObject() { + @Override public float weightedX() { return x; } + @Override public float weightedY() { return y; } + @Override public float weightedWidth() { return width; } + @Override public float weightedHeight() { return height; } + @Override public int pressureMass() { return mass; } + @Override public boolean activatesPressure() { return active; } + }; + } +} diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index fd4c785..a794bea 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -160,6 +160,13 @@ baseline-задачи также имели явного владельца по переход дальше; границы доступного human evidence зафиксированы в `STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 6 разблокирован. +Текущий статус Stage 6: foundation, production hazards, burned/crushed corpse +states и weighted objects приняты. Каталог содержит 17 уровней; последний exact +candidate прошёл `108` app JVM, `2` content-tool JVM и нефильтрованную Android 16 +матрицу `48/48` за `2,223.695 s`. Следующий dependency-order slice — challenges +и production progression, после него начинается массовое производство четырёх +миров. + ### Этап 6. Production content — P1, несколько итераций Задачи: @@ -191,7 +198,10 @@ data-driven модель. Завершён первый mechanics slice: product 99 JVM-тестов и Android 16 matrix `43/43` (`2004.172 s`). Следующая зависимая итерация завершена: `CRUSHED` и `BURNED`, save migration и обучающие уровни 15–16 приняты clean gate `102 JVM` и unfiltered Android matrix `46/46` -(`1,842.248 s`). Доказательства зафиксированы в `STAGE6_CORPSE_STATES.md`. +(`1,842.248 s`). Weighted-object slice также принят: общий `IWeightedObject`, +Box/Rock/Ball/IceBlock/Counterweight, save v4 и обучающий уровень 17 прошли clean +gate `108` app JVM + `2` content-tool JVM и unfiltered Android matrix `48/48` +(`2,223.695 s`). Evidence зафиксирован в `STAGE6_WEIGHTED_OBJECTS.md`. ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней diff --git a/docs/LEVEL_SOLUTION_GRAPHS.md b/docs/LEVEL_SOLUTION_GRAPHS.md index 50a37f7..47fd682 100644 --- a/docs/LEVEL_SOLUTION_GRAPHS.md +++ b/docs/LEVEL_SOLUTION_GRAPHS.md @@ -1,8 +1,8 @@ -# Графы решений уровней 1–16 +# Графы решений уровней 1–17 Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения. -На 19 августа 2026 года intended route всех уровней 1–16 автоматически выполнен по три раза подряд без retry masking. Device-тесты управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`. +На 19 августа 2026 года intended route всех уровней 1–17 автоматически выполнен по три раза подряд без retry masking. Device-тесты управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`. ## Семантика, общая для графов @@ -405,6 +405,35 @@ Moving bridge над PIT управляется signal "burn-weight" и нижней координатой моста. Save version 3 сохраняет флаг; Canvas добавляет текстовую метку `BURNED`, поэтому состояние не кодируется только цветом. +## Уровень 17 — `The Whole Weight` + +**Статус:** доказан production-input ботом, 3/3 последовательных прохождения. +**Ожидаемые и доказанные смерти:** `0`; дверь обязана удерживаться только +физическими объектами после отпускания player input. + +```text +Door требует signal "weight-gate" с requiredMass=9 +→ на PRESSURE уже стоят Box(1), Rock(2), Ball(1) и IceBlock(2): сумма 6 +→ Counterweight(3) находится слева от плиты, поэтому Door закрыта +→ игрок подходит и толкает Counterweight вправо +→ при x≥920 Counterweight пересекает plate x=1000..1650 +→ общий IWeightedObject resolver получает массу 6+3=9 и открывает Door +→ игрок отпускает input; объект продолжает удерживать сигнал без player mass +→ игрок двумя отдельными прыжками пересекает Counterweight и ряд объектов +→ проходит открытую Door и достигает goal +→ COMPLETE без смерти и без создания тел +``` + +**Контракт:** `Player`, `CorpseManager.Body` и `PhysicalObjectState` реализуют +один `IWeightedObject`; plate агрегирует только геометрически пересекающуюся +активную массу, не проверяя тип. Все пять authored типов имеют type/box/mass в +JSON, детерминированный runtime, non-color type+weight label, SVG/TSV preview и +save-v4 состояние. V1–v3 active sessions мигрируют с пустым runtime-object list. + +**Bot checkpoints:** каталог публикует пять objects и Counterweight mass 3; +до push `allDoorsOpen=false`; после push `firstPhysicalObjectX≥920` и Door open; +после release Door остаётся open; затем два отдельных jump edge и `completed=true`. + ## Проверки, следующие из графов 1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8. diff --git a/docs/STAGE4_CONTENT_FORMAT.md b/docs/STAGE4_CONTENT_FORMAT.md index e543279..18d1858 100644 --- a/docs/STAGE4_CONTENT_FORMAT.md +++ b/docs/STAGE4_CONTENT_FORMAT.md @@ -2,7 +2,7 @@ ## Назначение -Все десять уровней находятся в Android-independent модуле `level-format` и +Все уровни каталога находятся в Android-independent модуле `level-format` и загружаются из `level-format/src/main/resources/levels`. Runtime, unit-тесты и desktop-команды используют один и тот же JSON parser, catalog loader и validator. Изменение геометрии или текста не требует изменения игрового Java-кода. @@ -48,6 +48,9 @@ desktop-команды используют один и тот же JSON parser, "spawn": {"x": 130, "y": 760}, "goal": {"x": 1810, "y": 770, "width": 86, "height": 110}, "targets": {"deaths": 1, "seconds": 50}, + "objects": [ + {"type": "COUNTERWEIGHT", "box": {"x": 300, "y": 780, "width": 80, "height": 100}, "mass": 3} + ], "platforms": [ {"box": {"x": 0, "y": 880, "width": 2050, "height": 200}} ], @@ -68,7 +71,10 @@ desktop-команды используют один и тот же JSON parser, Поддерживаемые значения: -- hazards: `SPIKES`, `SAW`, `FREEZER`, `ELECTRIC`, `PIT`; +- hazards: `SPIKES`, `SAW`, `FREEZER`, `ELECTRIC`, `PIT`, `CRUSHER`, `FIRE`, + `PROJECTILE`, `EXPLOSION`; +- physical objects: `BOX`, `ROCK`, `BALL`, `ICE_BLOCK`, `COUNTERWEIGHT`; каждый + задаёт `box` и положительный integer `mass`; - switches: `PRESSURE`, `TOGGLE`, `TIMED`; - static platform: только `box`; - moving platform: `box`, ненулевой `travelX` или `travelY`, положительный @@ -82,7 +88,7 @@ desktop-команды используют один и тот же JSON parser, - известные format/physics versions; - конечные положительные размеры мира и объектов; -- spawn, goal, платформы, hazards, switches, doors и конечную позицию moving +- spawn, goal, physical objects, платформы, hazards, switches, doors и конечную позицию moving platform внутри мира; - наличие хотя бы одной платформы; - неотрицательный target deaths и положительный target seconds; @@ -115,9 +121,9 @@ level-03.json.doors[0].requiredSignals[0] references missing signal 'missing' Результат находится в `level-format/build/level-previews`: -- `level-01.svg` … `level-10.svg` — world-space схема spawn/goal, платформ, - hazards, switches и doors; -- `catalog.tsv` — координаты, размеры, travel и signal/control references. +- `level-01.svg` … последний уровень каталога — world-space схема spawn/goal, + objects, платформ, hazards, switches и doors; +- `catalog.tsv` — координаты, размеры, object mass, travel и signal/control references. Вывод использует стабильный порядок и locale-independent числа. Два запуска на неизменном каталоге должны давать byte-identical файлы. @@ -136,4 +142,3 @@ Wall-clock, device identifiers и прямые mutation-команды не за `traceId` — SHA-256 точного UTF-8 JSON и всегда включён в failure diagnostic. Stage 4 сохраняет воспроизводимый артефакт расследования, но намеренно не добавляет runtime replay executor. - diff --git a/docs/STAGE6_WEIGHTED_OBJECTS.md b/docs/STAGE6_WEIGHTED_OBJECTS.md new file mode 100644 index 0000000..1796b31 --- /dev/null +++ b/docs/STAGE6_WEIGHTED_OBJECTS.md @@ -0,0 +1,54 @@ +# Stage 6 weighted objects + +Date: 19 August 2026 + +Beads task: `goon-game-weighted-objects` + +## Contract + +`IWeightedObject` is the only pressure-mass contract. It exposes world geometry, +integer pressure mass, and whether the object may activate a mechanism. The live +player, retained corpse bodies, and authored physical objects implement it; +`PressureMassResolver` knows no concrete object types. + +The version-1 level schema now accepts an optional `objects` array with a strict +`type`, `box`, and positive `mass`. Supported production types are `BOX`, `ROCK`, +`BALL`, `ICE_BLOCK`, and `COUNTERWEIGHT`, matching the Beads scope. Existing JSON +without `objects` stays source-compatible. Validation, deterministic SVG/TSV +preview, Canvas rendering, collision/push physics, moving-platform support, +automation and performance accounting all consume the same definitions. + +Active-session save version 4 stores object type, geometry, velocity, mass, +grounding and moving-platform support. V1–v3 decode with an empty runtime object +list; levels from those versions had no authored physical objects, so restore +uses the level's initial definitions without inventing state. + +## Teaching puzzle + +Level 17, `The Whole Weight`, places Box(1), Rock(2), Ball(1), and IceBlock(2) on +a pressure plate. Their total 6 cannot open the mass-9 door. The player must push +Counterweight(3) onto the plate, release input to prove object-only retention, +then jump across the weighted row and pass the open door. + +## TDD and acceptance evidence + +- RED: missing `IWeightedObject`/resolver and all JSON object symbols (28 compile + errors); GREEN: shared pure mass aggregation and five parsed types. +- RED: missing runtime/save-v4 symbols (13 compile errors); GREEN: deterministic + state, corpse interface parity, v4 round-trip and v1–v3 migrations. +- RED: catalog expected 17 but contained 16; GREEN: validator `17/17` and bounded + two-page pagination. +- RED: Android scenario class absent; GREEN: compiled production-input route. +- RED: preview lacked object geometry; GREEN: byte-stable SVG and TSV include + object type and mass. +- Focused push/door/save integration: `1/1` GREEN in `9.128 s`. +- Level 17 intended route: `3/3` GREEN in `31.119 s`, exact zero deaths/bodies. +- Focused player/corpse/object pressure and restore matrix: `8/8` GREEN in + `315.926 s`. +- Clean gate: app JVM `108/108`, content-tool JVM `2/2`, validator `17/17`, + lint, app APK and test APK GREEN. +- Final exact-candidate unfiltered Android matrix: `48/48` GREEN in + `2,223.695 s`, with zero failures and zero skipped tests. +- Debug APK ZIP/signature: GREEN; packaged level JSON count: `17`. +- Debug APK SHA-256: + `141eecd3605d5cc5ab8ac20b52b666650b672c0723e2a3f8ea2187e75adca002`. diff --git a/docs/STAGE_REGISTRY.md b/docs/STAGE_REGISTRY.md index d62e73d..35939bb 100644 --- a/docs/STAGE_REGISTRY.md +++ b/docs/STAGE_REGISTRY.md @@ -65,7 +65,7 @@ Snapshot на 19 августа 2026 года: | 3 | 5 | 0 | | 4 | 4 | 0 | | 5 | 15 | 0 | -| 6 | 18 | 10 | +| 6 | 18 | 8 | | 7 | 9 | 9 | | 8 | 1 | 1 | | 9 | 1 | 1 | diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java b/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java index 2bbb200..8bff3cf 100644 --- a/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java @@ -72,6 +72,10 @@ public final class LevelContentTool { for (int i = 0; i < level.platforms.size(); i++) { rect(value, "platform", i, level.platforms.get(i).box, "#64748b"); } + for (int i = 0; i < level.objects.size(); i++) { + LevelDefinition.PhysicalObject item = level.objects.get(i); + rect(value, "object-" + item.type, i, item.box, "#f59e0b"); + } for (int i = 0; i < level.hazards.size(); i++) { rect(value, "hazard-" + level.hazards.get(i).type, i, level.hazards.get(i).box, "#ef4444"); @@ -108,6 +112,11 @@ public final class LevelContentTool { row(value, level.number, "platform", i, item.isMoving() ? "MOVING" : "STATIC", item.box, item.travelX, item.travelY, item.controlId); } + for (int i = 0; i < level.objects.size(); i++) { + LevelDefinition.PhysicalObject item = level.objects.get(i); + row(value, level.number, "object", i, item.type.name(), item.box, + 0f, 0f, "mass=" + item.mass); + } for (int i = 0; i < level.hazards.size(); i++) { LevelDefinition.Hazard item = level.hazards.get(i); row(value, level.number, "hazard", i, item.type.name(), item.box, diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java b/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java index 4173ca3..b9926d7 100644 --- a/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java @@ -10,6 +10,7 @@ public final class LevelDefinition { SPIKES, SAW, FREEZER, ELECTRIC, PIT, CRUSHER, FIRE, PROJECTILE, EXPLOSION } public enum SwitchType { PRESSURE, TOGGLE, TIMED } + public enum PhysicalObjectType { BOX, ROCK, BALL, ICE_BLOCK, COUNTERWEIGHT } public static final class Box { public final float x; @@ -115,6 +116,18 @@ public final class LevelDefinition { } } + public static final class PhysicalObject { + public final PhysicalObjectType type; + public final Box box; + public final int mass; + + public PhysicalObject(PhysicalObjectType type, Box box, int mass) { + this.type = type; + this.box = box; + this.mass = mass; + } + } + public final int number; public final int formatVersion; public final int physicsVersion; @@ -129,6 +142,7 @@ public final class LevelDefinition { public final Box goal; public final int targetDeaths; public final int targetSeconds; + public final List objects; public final List platforms; public final List hazards; public final List switches; @@ -151,7 +165,7 @@ public final class LevelDefinition { ) { this(1, 1, number, title, hint, title, hint, worldWidth, 1080f, spawnX, spawnY, goal, targetDeaths, targetSeconds, - platforms, hazards, switches, doors); + Collections.emptyList(), platforms, hazards, switches, doors); } public LevelDefinition( @@ -173,6 +187,32 @@ public final class LevelDefinition { List hazards, List switches, List doors + ) { + this(formatVersion, physicsVersion, number, title, hint, titleRu, hintRu, + worldWidth, worldHeight, spawnX, spawnY, goal, targetDeaths, + targetSeconds, Collections.emptyList(), platforms, hazards, switches, doors); + } + + public LevelDefinition( + int formatVersion, + int physicsVersion, + int number, + String title, + String hint, + String titleRu, + String hintRu, + float worldWidth, + float worldHeight, + float spawnX, + float spawnY, + Box goal, + int targetDeaths, + int targetSeconds, + List objects, + List platforms, + List hazards, + List switches, + List doors ) { this.formatVersion = formatVersion; this.physicsVersion = physicsVersion; @@ -188,6 +228,7 @@ public final class LevelDefinition { this.goal = goal; this.targetDeaths = targetDeaths; this.targetSeconds = targetSeconds; + this.objects = Collections.unmodifiableList(objects); this.platforms = Collections.unmodifiableList(platforms); this.hazards = Collections.unmodifiableList(hazards); this.switches = Collections.unmodifiableList(switches); diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java b/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java index 4fb284d..6268453 100644 --- a/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java @@ -37,6 +37,7 @@ public final class LevelJsonLoader { optionalText(value, "controlId", path))); } + List objects = physicalObjects(root, source); List hazards = hazards(root, source); List switches = switches(root, source); List doors = doors(root, source); @@ -56,12 +57,29 @@ public final class LevelJsonLoader { box(required(root, "goal", source), source + ".goal"), integer(targets, "deaths", source + ".targets"), integer(targets, "seconds", source + ".targets"), + objects, platforms, hazards, switches, doors), source); } + private static List physicalObjects( + Map root, String source) { + if (!root.containsKey("objects")) return Collections.emptyList(); + List values = array(root.get("objects"), source + ".objects"); + List result = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + String path = source + ".objects[" + i + "]"; + Map value = object(values.get(i), path); + result.add(new LevelDefinition.PhysicalObject( + physicalObjectType(text(value, "type", path), path + ".type"), + box(required(value, "box", path), path + ".box"), + integer(value, "mass", path))); + } + return result; + } + static List loadIndex(Reader reader, String sourceName) { String source = sourceName == null ? "" : sourceName; Map root = object(new JsonValueParser(reader, source).parse(), source); @@ -171,6 +189,15 @@ public final class LevelJsonLoader { } } + private static LevelDefinition.PhysicalObjectType physicalObjectType( + String value, String path) { + try { + return LevelDefinition.PhysicalObjectType.valueOf(value); + } catch (IllegalArgumentException error) { + throw invalid(path, "has unknown physical object type '" + value + "'"); + } + } + private static void requireEmptyArray(Map parent, String key, String path) { List values = array(required(parent, key, path), path + "." + key); if (!values.isEmpty()) { diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java b/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java index f1b43f5..4216df4 100644 --- a/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java @@ -25,6 +25,13 @@ public final class LevelValidator { if (level.platforms.isEmpty()) { throw invalid(source + ".platforms", "must contain at least one platform"); } + for (int i = 0; i < level.objects.size(); i++) { + LevelDefinition.PhysicalObject object = level.objects.get(i); + String path = source + ".objects[" + i + "]"; + if (object.type == null) throw invalid(path + ".type", "must be known"); + box(object.box, level, path + ".box"); + if (object.mass <= 0) throw invalid(path + ".mass", "must be positive"); + } for (int i = 0; i < level.platforms.size(); i++) { LevelDefinition.Platform platform = level.platforms.get(i); String path = source + ".platforms[" + i + "]"; diff --git a/level-format/src/main/resources/levels/index.json b/level-format/src/main/resources/levels/index.json index 8d0912a..77d28b6 100644 --- a/level-format/src/main/resources/levels/index.json +++ b/level-format/src/main/resources/levels/index.json @@ -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"]} +{"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"]} diff --git a/level-format/src/main/resources/levels/level-17.json b/level-format/src/main/resources/levels/level-17.json new file mode 100644 index 0000000..184aa75 --- /dev/null +++ b/level-format/src/main/resources/levels/level-17.json @@ -0,0 +1,17 @@ +{ + "formatVersion":1,"physicsVersion":1,"number":17, + "copy":{"en":{"title":"The Whole Weight","hint":"Push the counterweight onto the plate. Every object adds its marked weight."},"ru":{"title":"Полный вес","hint":"Толкните противовес на плиту. Каждый объект добавляет указанный вес."}}, + "world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760}, + "goal":{"x":2300,"y":770,"width":86,"height":110},"targets":{"deaths":0,"seconds":45}, + "objects":[ + {"type":"COUNTERWEIGHT","box":{"x":650,"y":780,"width":90,"height":100},"mass":3}, + {"type":"BOX","box":{"x":1250,"y":800,"width":80,"height":80},"mass":1}, + {"type":"ROCK","box":{"x":1360,"y":820,"width":60,"height":60},"mass":2}, + {"type":"BALL","box":{"x":1450,"y":820,"width":60,"height":60},"mass":1}, + {"type":"ICE_BLOCK","box":{"x":1530,"y":800,"width":80,"height":80},"mass":2} + ], + "platforms":[{"box":{"x":0,"y":880,"width":2500,"height":200}}], + "hazards":[], + "switches":[{"type":"PRESSURE","box":{"x":1000,"y":860,"width":650,"height":20},"signalId":"weight-gate","durationSeconds":0,"requiredMass":9}], + "doors":[{"box":{"x":1750,"y":560,"width":82,"height":320},"requiredSignals":["weight-gate"]}] +} diff --git a/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java b/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java index 8fbe391..3432dff 100644 --- a/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java +++ b/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java @@ -60,6 +60,10 @@ public final class LevelContentToolTest { assertTrue(svg, svg.contains("viewBox=\"0 0 1920 1080\"")); assertTrue(svg, svg.contains("data-kind=\"spawn\"")); assertTrue(svg, svg.contains("data-kind=\"goal\"")); + assertTrue(svg, svg.contains("data-kind=\"object-COUNTERWEIGHT\"")); + String coordinates = Files.readString(first.resolve("catalog.tsv")); + assertTrue(coordinates, coordinates.contains("1\tobject\t0\tCOUNTERWEIGHT")); + assertTrue(coordinates, coordinates.contains("mass=3")); } private static void writeFixture(Path levels) throws Exception { @@ -76,6 +80,9 @@ public final class LevelContentToolTest { + "\"spawn\":{\"x\":130,\"y\":760}," + "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110}," + "\"targets\":{\"deaths\":0,\"seconds\":30}," + + "\"objects\":[{\"type\":\"COUNTERWEIGHT\"," + + "\"box\":{\"x\":700,\"y\":780,\"width\":80,\"height\":100}," + + "\"mass\":3}]," + "\"platforms\":[{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}]," + "\"hazards\":[],\"switches\":[],\"doors\":[]}"; }