Добавить production-состояния тел

This commit is contained in:
2026-08-19 06:34:40 +03:00
parent dc2ebcbf8c
commit 19bb64edfb
21 changed files with 580 additions and 31 deletions
+2 -2
View File
@@ -23,7 +23,7 @@
{"_type":"issue","id":"goon-game-e12","title":"EPIC-12 Android","description":"Lifecycle, устройства, aspect ratios, graphics profiles и сборки. Разделы 41, 44–46, 49.","acceptance_criteria":"Release build стабильно работает на целевой Android-матрице.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:08Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:47Z","metadata":{"stages":"0,3,5,7","primary_stage":7},"labels":["android","epic","primary-stage-7","stage-0","stage-3","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e12","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:55Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e07","title":"EPIC-07 Levels","description":"Solution graphs, greybox, прогрессия механик и производство 55–70 уровней. Разделы 19–25, 52–53, 66.","acceptance_criteria":"Каждый уровень имеет доказанное решение, DoD и место в content matrix.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:57Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:25Z","metadata":{"stages":"0,2,4,6","primary_stage":6},"labels":["content","epic","levels","primary-stage-6","stage-0","stage-2","stage-4","stage-6"],"dependencies":[{"issue_id":"goon-game-e07","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:49Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e03","title":"EPIC-03 Physics","description":"Предсказуемая гибридная физика и бюджеты активных объектов. Разделы 15, 41–43, 65.","acceptance_criteria":"Одинаковая последовательность ввода воспроизводимо приводит к одинаковому состоянию головоломки.","notes":"Stage 5 closed the deterministic physics contract: PhysicsConfig v1 and 10 exact command traces replayed 3x with identical signatures; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:02:55Z","closed_at":"2026-08-18T23:02:55Z","close_reason":"Physics foundation and deterministic replay acceptance are complete.","metadata":{"stages":"0,5,7","primary_stage":5},"labels":["epic","physics","primary-stage-5","stage-0","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e03","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e02","title":"EPIC-02 Death \u0026 Corpse","description":"Смерть как игровая механика, состояния тела, CorpseManager и respawn. Разделы 4–8.","acceptance_criteria":"Все типы смерти создают предсказуемое и пригодное для головоломки состояние тела.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:03Z","metadata":{"stages":"0,6,7","primary_stage":6},"labels":["corpse","death","epic","primary-stage-6","stage-0","stage-6","stage-7"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e02","title":"EPIC-02 Death \u0026 Corpse","description":"Смерть как игровая механика, состояния тела, CorpseManager и respawn. Разделы 4–8.","acceptance_criteria":"Все типы смерти создают предсказуемое и пригодное для головоломки состояние тела.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:50Z","closed_at":"2026-08-19T03:32:50Z","close_reason":"All EPIC-02 children complete: death/respawn core, CorpseManager ownership, and every approved corpse state has deterministic runtime and puzzle evidence.","metadata":{"stages":"0,6,7","primary_stage":6},"labels":["corpse","death","epic","primary-stage-6","stage-0","stage-6","stage-7"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-plan","title":"Project Sacrifice: выполнить утверждённый production plan","description":"Главный bead для плана из docs/DEVELOPMENT_PLAN.md: vertical slice, production, alpha, beta и release candidate.","acceptance_criteria":"Release Candidate удовлетворяет критериям раздела 59; все дочерние эпики завершены либо явно исключены решением владельца продукта.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:23Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:51Z","started_at":"2026-08-17T21:41:28Z","metadata":{"stages":"0,1,2,3,4,5,6,7,8,9,10","primary_stage":10},"labels":["gdd","master","primary-stage-10","production","stage-0","stage-1","stage-10","stage-2","stage-3","stage-4","stage-5","stage-6","stage-7","stage-8","stage-9","tdd"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-stage4-bot-traces","title":"Сохранять воспроизводимый журнал команд бота","description":"Добавить versioned trace contract для production-input сценариев с level/format/physics versions, simulation timestamps, inputs и ключевыми checkpoints.","design":"Trace recorder наблюдает публичный Driver/Input контракт и не управляет GameView напрямую; replay engine вне Stage 4 scope, журнал должен быть пригоден для последующего replay.","acceptance_criteria":"Каждый bot runner может выдать deterministic JSON trace; повторная сериализация идентична; timeout diagnostics указывает trace; sensitive/device-local data не записываются.","notes":"GREEN: trace v1 records simulation inputs/checkpoints/outcome plus format/physics versions; SHA-256 trace ID is included in diagnostics; generic and level-8 runners covered.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:04Z","started_at":"2026-08-18T19:01:26Z","closed_at":"2026-08-18T19:01:34Z","close_reason":"Deterministic versioned traces and diagnostic IDs implemented for all bot runner families.","metadata":{"stages":4,"primary_stage":4},"labels":["bot","diagnostics","primary-stage-4","replay","stage-4","stage4"],"dependencies":[{"issue_id":"goon-game-stage4-bot-traces","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:32Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-stage4-validator-tools","title":"Добавить build validator и desktop preview/export","description":"Создать запускаемый на desktop инструмент, который валидирует каталог до Android-сборки и экспортирует координаты в читаемый SVG/текстовый отчёт.","design":"Инструмент использует ту же pure-Java загрузку и validator, что runtime, без дублирования правил в Gradle или Python.","acceptance_criteria":"Gradle build отклоняет broken fixture до упаковки APK; CLI валидирует все levels и создаёт deterministic preview/export; команда и формат задокументированы.","notes":"GREEN: preBuild validateLevels, path-specific broken-signal rejection, deterministic 10 SVG + catalog.tsv preview.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T17:54:28Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:04Z","started_at":"2026-08-18T19:01:11Z","closed_at":"2026-08-18T19:01:21Z","close_reason":"Shared build validator and deterministic desktop preview/export implemented, documented, and verified.","metadata":{"stages":4,"primary_stage":4},"labels":["primary-stage-4","stage-4","stage4","tooling","validator"],"dependencies":[{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-content-architecture","type":"parent-child","created_at":"2026-08-18T20:54:31Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-stage4-validator-tools","depends_on_id":"goon-game-stage4-level-json","type":"blocks","created_at":"2026-08-18T20:54:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
@@ -70,7 +70,7 @@
{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","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 механики.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"},{"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-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","notes":"2026-08-19: claimed after production hazards dc2ebcb passed 99 JVM and Android 43/43. Will add explicit crushed/burned/shattered and derived corpse behavior test-first, then one teaching route/solution graph per new gameplay-relevant state and full regression.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:49Z","started_at":"2026-08-19T02:31:25Z","closed_at":"2026-08-19T03:32:49Z","close_reason":"GDD corpse states complete: existing normal/pinned/frozen/conductive routes plus new burned/crushed contracts, save v3 migration, levels 15-16 and bots. Clean 102 JVM; exact Android 46/46 in 1842.248s. IsDestroyed is a lifecycle field; Shattered is not an approved GDD state.","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-hazards-full","type":"blocks","created_at":"2026-08-19T04:07:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e14","title":"EPIC-14 Analytics","description":"События прохождения, позиции смертей и crash reporting без лишних персональных данных. Раздел 47.","acceptance_criteria":"События и ошибки проверены в тестовом канале, схема версионирована.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:12Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:57Z","metadata":{"stages":"7,8,9,10","primary_stage":7},"labels":["analytics","epic","primary-stage-7","stage-10","stage-7","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e14","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:57Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e11","title":"EPIC-11 Art","description":"Самостоятельное художественное направление, модульные персонажи и анимации. Разделы 26–28.","acceptance_criteria":"Визуальный язык утверждён, оригинален и покрывает все игровые состояния.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:44Z","metadata":{"stages":"5,6","primary_stage":6},"labels":["art","epic","primary-stage-6","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e11","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e10","title":"EPIC-10 Audio","description":"Sound design, вариативность реакций и музыкальные loops. Разделы 29–30.","acceptance_criteria":"Vertical slice имеет полный audio pass без раздражающих повторов.","notes":"Stage 5 audio acceptance complete: procedural music/effects/voice variation, lifecycle safety and independent persisted controls; the EPIC child is closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:03Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:09Z","closed_at":"2026-08-18T23:03:09Z","close_reason":"Vertical Slice has a complete non-repeating audio pass.","metadata":{"stages":5,"primary_stage":5},"labels":["audio","epic","primary-stage-5","stage-5"],"dependencies":[{"issue_id":"goon-game-e10","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:52Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
+13 -5
View File
@@ -11,13 +11,14 @@ Project documents:
- [`docs/STAGE5_ART_BIBLE.md`](docs/STAGE5_ART_BIBLE.md) — original visual language and articulated volunteer contract;
- [`docs/STAGE5_PERFORMANCE_BUDGETS.md`](docs/STAGE5_PERFORMANCE_BUDGETS.md) — physics, replay, frame, memory, and complexity ceilings;
- [`docs/STAGE5_QA_REPORT.md`](docs/STAGE5_QA_REPORT.md) — exact Vertical Slice candidate evidence;
- [`docs/STAGE6_CORPSE_STATES.md`](docs/STAGE6_CORPSE_STATES.md) — burned/crushed body contracts and acceptance evidence;
- [`docs/STAGE5_HUMAN_PLAYTEST.md`](docs/STAGE5_HUMAN_PLAYTEST.md) — required external acceptance protocol;
- [`docs/STAGE_REGISTRY.md`](docs/STAGE_REGISTRY.md) — mandatory Stage 0–10 ownership for every Bead;
- [`docs/BEADS_WORKFLOW.md`](docs/BEADS_WORKFLOW.md) — local backlog structure and daily Beads commands.
## Playable content
The APK contains ten sequential levels:
The APK contains sixteen sequential levels:
1. movement and jumping;
2. bodies covering a spike lane;
@@ -28,7 +29,13 @@ The APK contains ten sequential levels:
7. an electrified corpse completing a relay;
8. a timed switch and moving platform;
9. a two-signal door requiring weight and electricity;
10. a mastery level combining saw, freeze, electricity, pressure plate, pit, moving lift, and final gate.
10. a mastery level combining saw, freeze, electricity, pressure plate, pit, moving lift, and final gate;
11. a rhythmic crusher and its safe rest phase;
12. a fire lane with a staged jump;
13. two state-driven projectile crossings;
14. explosion delivery to a remote pressure door;
15. a crushed body whose low collider becomes a required step;
16. a burned body that stays solid but no longer powers a pressure bridge.
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.
@@ -72,7 +79,7 @@ Previews are written to `level-format/build/level-previews`. Invalid geometry,
versions, moving-platform destinations, switch parameters, or signal references
fail the build with a JSON path diagnostic.
All ten levels have device-level acceptance bots. They drive the production
All sixteen 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
@@ -112,8 +119,9 @@ mechanics slice adds data-driven crusher, fire, projectile and explosion hazards
teaching levels 11–14, dynamic level pagination and production-input routes.
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`. The latest unfiltered
gate is `99 JVM / 43 Android`.
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.
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with:
@@ -0,0 +1,40 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class CorpseStateIntegrationTest {
@Test
public void crusherAndFireDeathsPublishDistinctPuzzleBodyStates() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
view.enableDeterministicAutomationClock();
view.startLevelForAutomation(11);
view.setAutomationInput(false, true, false);
view.advanceDeterministicAutomation(2_000L);
GameView.AutomationSnapshot crushed = view.automationSnapshot();
assertTrue(crushed.firstCorpseCrushed());
assertFalse(crushed.firstCorpseBurned());
assertTrue(crushed.firstCorpseActivatesPressure());
assertTrue(crushed.firstCorpseWidth() > crushed.firstCorpseHeight());
view.startLevelForAutomation(12);
view.setAutomationInput(false, true, false);
view.advanceDeterministicAutomation(2_000L);
GameView.AutomationSnapshot burned = view.automationSnapshot();
assertTrue(burned.firstCorpseBurned());
assertFalse(burned.firstCorpseCrushed());
assertFalse(burned.firstCorpseActivatesPressure());
});
}
}
}
@@ -0,0 +1,81 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.app.Instrumentation;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class CorpseStatePlaythroughTest {
private static final int CONSECUTIVE_RUNS = 3;
@Test public void crushedBodyIsARequiredLowStepThreeTimes() {
prove(CorpseStateScenarios.levelFifteen(), 45_000L);
}
@Test public void burnedBodyLeavesThePressureBridgeDormantThreeTimes() {
prove(CorpseStateScenarios.levelSixteen(), 45_000L);
}
private static void prove(LevelPlaytestBot.Scenario scenario, long budget) {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
AndroidDriver driver = AndroidDriver.from(activity);
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, budget);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run + "; route uses exactly one transformed body",
1, result.deaths());
assertEquals("run=" + run + "; transformed body must remain",
1, result.corpseCount());
}
}
}
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView gameView;
private AndroidDriver(Instrumentation instrumentation, GameView gameView) {
this.instrumentation = instrumentation;
this.gameView = gameView;
}
static AndroidDriver from(ActivityScenario<MainActivity> activity) {
GameView[] view = new GameView[1];
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]);
}
@Override public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
}
@Override public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
instrumentation.runOnMainSync(
() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
}
@Override public GameView.AutomationSnapshot snapshot() {
GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> snapshot[0] = gameView.automationSnapshot());
return snapshot[0];
}
@Override public void waitForNextDecision(long millis) {
try {
Thread.sleep(Math.max(32L, millis));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Corpse-state bot interrupted", interrupted);
}
}
}
}
@@ -0,0 +1,93 @@
package com.projectsacrifice.game;
/** Intended routes whose solution depends on the transformed corpse state. */
final class CorpseStateScenarios {
private CorpseStateScenarios() {}
static LevelPlaytestBot.Scenario levelFifteen() {
return LevelPlaytestBot.Scenario.builder(15, "Level 15 - crushed low step")
.step("Create one crushed body", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1
&& snapshot.firstCorpseCrushed()
&& snapshot.firstCorpseWidth() > snapshot.firstCorpseHeight())
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the press step", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Observe a fresh press cycle", 4_000L,
LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::firstHazardActive)
.step("Wait for the full rest window", 4_000L,
LevelPlaytestBot.Input.none(),
snapshot -> !snapshot.firstHazardActive())
.step("Land on the crushed low step", 2_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerX() >= 690f
&& snapshot.playerY() <= 757f))
.step("Jump from the step to the ledge", 2_500L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerX() >= 850f
&& snapshot.playerY() <= 621f))
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
GameView.AutomationSnapshot::completed))
.build();
}
static LevelPlaytestBot.Scenario levelSixteen() {
return LevelPlaytestBot.Scenario.builder(16, "Level 16 - burned pressure immunity")
.step("Create one burned body on the plate", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1
&& snapshot.firstCorpseBurned()
&& !snapshot.firstCorpseActivatesPressure())
.step("Prove the burned body leaves the bridge dormant", 2_000L,
LevelPlaytestBot.Input.none(),
snapshot -> !snapshot.firstMovingPlatformPowered()
&& snapshot.firstMovingPlatformY() >= 800f)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the flame before touching its plate", 2_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(535f)))
.step("Take off over flame and plate", 1_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 700f))
.step("Clear the burned plate", 2_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 850f))
.step("Approach the dormant bridge", 1_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 900f
&& !snapshot.firstMovingPlatformPowered())
.step("Board the dormant bridge", 2_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
GameView.AutomationSnapshot::playerOnMovingPlatform))
.step("Cross to the far floor", 3_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_450f))
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
GameView.AutomationSnapshot::completed))
.build();
}
}
@@ -36,6 +36,7 @@ public final class LevelPaginationTest {
List<String> visible = view.visibleLocalizedTextForAutomation();
assertEquals(1, view.levelPageIndexForAutomation());
assertTrue(visible.contains("Steam Charge"));
assertTrue(visible.contains("Ashen Counterweight"));
assertFalse(visible.contains("The Volunteer"));
assertTrue(visible.contains("Page 2 of 2"));
});
@@ -9,7 +9,9 @@ public final class CorpseManager {
public enum Transformation {
PINNED,
FROZEN,
CONDUCTIVE
CONDUCTIVE,
BURNED,
CRUSHED
}
public static final class Body {
@@ -24,6 +26,8 @@ public final class CorpseManager {
boolean frozen;
boolean conductive;
boolean pinned;
boolean burned;
boolean crushed;
boolean grounded;
int supportIndex = -1;
@@ -45,6 +49,9 @@ public final class CorpseManager {
public boolean frozen() { return frozen; }
public boolean conductive() { return conductive; }
public boolean pinned() { return pinned; }
public boolean burned() { return burned; }
public boolean crushed() { return crushed; }
public boolean activatesPressure() { return !burned; }
public boolean grounded() { return grounded; }
public int supportIndex() { return supportIndex; }
@@ -148,6 +155,12 @@ public final class CorpseManager {
case CONDUCTIVE:
body.conductive = true;
break;
case BURNED:
body.burned = true;
break;
case CRUSHED:
body.crushed = true;
break;
}
return true;
}
@@ -43,7 +43,7 @@ public final class GameSessionSnapshot {
public static final class BodyData {
private final float x, y, width, height, velocityX, velocityY;
private final boolean frozen, conductive, pinned, grounded;
private final boolean frozen, conductive, pinned, grounded, crushed, burned;
private final int supportIndex;
private final String deathType;
@@ -51,6 +51,15 @@ public final class GameSessionSnapshot {
float velocityX, float velocityY, boolean frozen,
boolean conductive, boolean pinned, boolean grounded,
int supportIndex, String deathType) {
this(x, y, width, height, velocityX, velocityY, frozen, conductive,
pinned, grounded, false, false, supportIndex, deathType);
}
public BodyData(float x, float y, float width, float height,
float velocityX, float velocityY, boolean frozen,
boolean conductive, boolean pinned, boolean grounded,
boolean crushed, boolean burned,
int supportIndex, String deathType) {
this.x = x;
this.y = y;
this.width = width;
@@ -61,6 +70,8 @@ public final class GameSessionSnapshot {
this.conductive = conductive;
this.pinned = pinned;
this.grounded = grounded;
this.crushed = crushed;
this.burned = burned;
this.supportIndex = supportIndex;
this.deathType = deathType == null ? "SPIKE" : deathType;
}
@@ -75,6 +86,8 @@ public final class GameSessionSnapshot {
public boolean conductive() { return conductive; }
public boolean pinned() { return pinned; }
public boolean grounded() { return grounded; }
public boolean crushed() { return crushed; }
public boolean burned() { return burned; }
public int supportIndex() { return supportIndex; }
public String deathType() { return deathType; }
}
@@ -75,6 +75,11 @@ public final class GameView extends View implements Runnable {
private final boolean firstHazardActive;
private final float firstHazardX;
private final float firstHazardY;
private final boolean firstCorpseBurned;
private final boolean firstCorpseCrushed;
private final boolean firstCorpseActivatesPressure;
private final float firstCorpseWidth;
private final float firstCorpseHeight;
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
@@ -163,6 +168,31 @@ public final class GameView extends View implements Runnable {
boolean playerOnGround, float playerVelocityX,
float playerVelocityY, boolean firstHazardActive,
float firstHazardX, float firstHazardY) {
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, false, false, false,
Float.NaN, Float.NaN);
}
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) {
this.completed = completed;
this.playerAlive = playerAlive;
this.allDoorsOpen = allDoorsOpen;
@@ -187,6 +217,11 @@ public final class GameView extends View implements Runnable {
this.firstHazardActive = firstHazardActive;
this.firstHazardX = firstHazardX;
this.firstHazardY = firstHazardY;
this.firstCorpseBurned = firstCorpseBurned;
this.firstCorpseCrushed = firstCorpseCrushed;
this.firstCorpseActivatesPressure = firstCorpseActivatesPressure;
this.firstCorpseWidth = firstCorpseWidth;
this.firstCorpseHeight = firstCorpseHeight;
}
public boolean completed() { return completed; }
@@ -213,6 +248,13 @@ public final class GameView extends View implements Runnable {
public boolean firstHazardActive() { return firstHazardActive; }
public float firstHazardX() { return firstHazardX; }
public float firstHazardY() { return firstHazardY; }
public boolean firstCorpseBurned() { return firstCorpseBurned; }
public boolean firstCorpseCrushed() { return firstCorpseCrushed; }
public boolean firstCorpseActivatesPressure() {
return firstCorpseActivatesPressure;
}
public float firstCorpseWidth() { return firstCorpseWidth; }
public float firstCorpseHeight() { return firstCorpseHeight; }
}
private static final class Player {
@@ -593,6 +635,7 @@ public final class GameView extends View implements Runnable {
int mass = 0;
if (player.alive && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) mass++;
for (Body corpse : corpses) {
if (!corpse.activatesPressure()) continue;
if (intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) mass++;
}
return mass >= item.requiredMass;
@@ -868,6 +911,16 @@ public final class GameView extends View implements Runnable {
corpse.vx = corpse.vy = 0f;
corpseManager.transform(corpse, CorpseManager.Transformation.CONDUCTIVE);
corpseManager.transform(corpse, CorpseManager.Transformation.PINNED);
} else if (type == DeathType.FIRE) {
corpseManager.transform(corpse, CorpseManager.Transformation.BURNED);
} else if (type == DeathType.CRUSH && source != null) {
corpse.width = 140f;
corpse.height = 24f;
corpse.x = source.box.x - 10f;
corpse.y = source.box.bottom() - corpse.height;
corpse.vx = corpse.vy = 0f;
corpseManager.transform(corpse, CorpseManager.Transformation.CRUSHED);
corpseManager.transform(corpse, CorpseManager.Transformation.PINNED);
}
}
}
@@ -1305,6 +1358,19 @@ public final class GameView extends View implements Runnable {
}
private void drawCorpse(Canvas canvas, Body corpse) {
if (corpse.crushed) {
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : Color.rgb(126, 93, 58));
RectF slab = corpseRect(corpse);
canvas.drawRoundRect(slab, 8f, 8f, paint);
stroke.setColor(accessibilitySettings.highContrastEnabled()
? Color.BLACK : Color.rgb(226, 177, 82));
stroke.setStrokeWidth(5f);
canvas.drawLine(slab.left + 10f, slab.centerY(),
slab.right - 10f, slab.centerY(), stroke);
drawWorldLabel(canvas, "CRUSHED", slab.centerX(), slab.top - 16f);
return;
}
canvas.save();
float cx = corpse.x + corpse.width * 0.5f;
float cy = corpse.y + corpse.height * 0.5f;
@@ -1319,6 +1385,17 @@ public final class GameView extends View implements Runnable {
levelElapsed * 1.6f, 1, 0.82f,
accessibilitySettings.highContrastEnabled());
canvas.restore();
if (corpse.burned) {
RectF charred = corpseRect(corpse);
paint.setColor(Color.argb(145, 28, 24, 29));
canvas.drawRoundRect(charred, 12f, 12f, paint);
stroke.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : Color.rgb(255, 138, 54));
stroke.setStrokeWidth(5f);
canvas.drawLine(charred.left + 10f, charred.bottom - 10f,
charred.right - 10f, charred.top + 10f, stroke);
drawWorldLabel(canvas, "BURNED", charred.centerX(), charred.top - 16f);
}
}
private void drawHud(Canvas canvas) {
@@ -2494,7 +2571,8 @@ public final class GameView extends View implements Runnable {
builder.addBody(new GameSessionSnapshot.BodyData(
corpse.x, corpse.y, corpse.width, corpse.height,
corpse.vx, corpse.vy, corpse.frozen, corpse.conductive,
corpse.pinned, corpse.grounded, corpse.supportIndex,
corpse.pinned, corpse.grounded, corpse.crushed, corpse.burned,
corpse.supportIndex,
corpse.deathType()));
}
ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size());
@@ -2579,6 +2657,12 @@ public final class GameView extends View implements Runnable {
if (savedBody.pinned()) {
corpseManager.transform(corpse, CorpseManager.Transformation.PINNED);
}
if (savedBody.crushed()) {
corpseManager.transform(corpse, CorpseManager.Transformation.CRUSHED);
}
if (savedBody.burned()) {
corpseManager.transform(corpse, CorpseManager.Transformation.BURNED);
}
}
updateSignals(0f, false);
@@ -2636,6 +2720,7 @@ public final class GameView extends View implements Runnable {
firstHazardX = firstHazardBox.x;
firstHazardY = firstHazardBox.y;
}
Body firstCorpse = corpses.isEmpty() ? null : corpses.get(0);
return new AutomationSnapshot(
screen == Screen.COMPLETE,
player.alive,
@@ -2660,7 +2745,12 @@ public final class GameView extends View implements Runnable {
player.vy,
firstHazardActive,
firstHazardX,
firstHazardY
firstHazardY,
firstCorpse != null && firstCorpse.burned(),
firstCorpse != null && firstCorpse.crushed(),
firstCorpse != null && firstCorpse.activatesPressure(),
firstCorpse == null ? Float.NaN : firstCorpse.width(),
firstCorpse == null ? Float.NaN : firstCorpse.height()
);
}
@@ -14,7 +14,7 @@ public final class VersionedSessionCodec {
private static final int MAX_BODIES = 16;
private static final int MAX_PLATFORMS = 64;
private static final int MAX_MAP_ENTRIES = 128;
public static final int CURRENT_VERSION = 2;
public static final int CURRENT_VERSION = 3;
public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT }
@@ -41,6 +41,10 @@ public final class VersionedSessionCodec {
return encodeWithVersion(snapshot, 1);
}
static byte[] encodeLegacyV2(GameSessionSnapshot snapshot) {
return encodeWithVersion(snapshot, 2);
}
static byte[] encodeVersionForTest(int version) {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
@@ -75,7 +79,7 @@ public final class VersionedSessionCodec {
.player(readPlayer(input));
int bodyCount = checkedCount(input.readInt(), MAX_BODIES);
for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input));
for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input, version));
int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS);
ArrayList<Float> motionTimes = new ArrayList<>(platformCount);
@@ -108,7 +112,9 @@ public final class VersionedSessionCodec {
output.writeFloat(snapshot.respawnSeconds());
writePlayer(output, snapshot.player());
output.writeInt(snapshot.bodies().size());
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) writeBody(output, body);
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) {
writeBody(output, body, version);
}
output.writeInt(snapshot.platformMotionTimes().size());
for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time);
writeBooleanMap(output, snapshot.toggleSignals());
@@ -145,7 +151,8 @@ public final class VersionedSessionCodec {
input.readInt(), input.readInt());
}
private static void writeBody(DataOutputStream output, GameSessionSnapshot.BodyData body)
private static void writeBody(DataOutputStream output, GameSessionSnapshot.BodyData body,
int version)
throws IOException {
output.writeFloat(body.x());
output.writeFloat(body.y());
@@ -157,11 +164,15 @@ public final class VersionedSessionCodec {
output.writeBoolean(body.conductive());
output.writeBoolean(body.pinned());
output.writeBoolean(body.grounded());
if (version >= 3) {
output.writeBoolean(body.crushed());
output.writeBoolean(body.burned());
}
output.writeInt(body.supportIndex());
output.writeUTF(body.deathType());
}
private static GameSessionSnapshot.BodyData readBody(DataInputStream input)
private static GameSessionSnapshot.BodyData readBody(DataInputStream input, int version)
throws IOException {
float x = finite(input.readFloat());
float y = finite(input.readFloat());
@@ -173,12 +184,14 @@ public final class VersionedSessionCodec {
boolean conductive = input.readBoolean();
boolean pinned = input.readBoolean();
boolean grounded = input.readBoolean();
boolean crushed = version >= 3 && input.readBoolean();
boolean burned = version >= 3 && input.readBoolean();
int support = input.readInt();
String deathType = input.readUTF();
if (deathType.length() > 24) throw new IllegalArgumentException("Death type too long");
return new GameSessionSnapshot.BodyData(x, y, width, height,
velocityX, velocityY, frozen, conductive, pinned, grounded,
support, deathType);
crushed, burned, support, deathType);
}
private static void writeBooleanMap(DataOutputStream output, Map<String, Boolean> values)
@@ -68,4 +68,34 @@ public final class CorpseManagerTest {
CorpseManager.Body foreign = new CorpseManager(1).register("SAW", 0f, 0f);
assertFalse(manager.transform(foreign, CorpseManager.Transformation.FROZEN));
}
@Test
public void burnedBodyRemainsPhysicalButCannotActivatePressureMechanisms() {
CorpseManager manager = new CorpseManager(2);
CorpseManager.Body body = manager.register("FIRE", 10f, 20f);
assertTrue(manager.transform(body, CorpseManager.Transformation.BURNED));
assertTrue(body.burned());
assertFalse(body.crushed());
assertFalse(body.activatesPressure());
assertTrue(manager.bodies().contains(body));
}
@Test
public void crushedBodyKeepsOtherTransformationsAndExposesItsLowProfile() {
CorpseManager manager = new CorpseManager(2);
CorpseManager.Body body = manager.register("CRUSH", 10f, 20f)
.size(140f, 24f);
assertTrue(manager.transform(body, CorpseManager.Transformation.PINNED));
assertTrue(manager.transform(body, CorpseManager.Transformation.CRUSHED));
assertTrue(body.crushed());
assertFalse(body.burned());
assertTrue(body.pinned());
assertTrue(body.activatesPressure());
assertEquals(140f, body.width(), 0f);
assertEquals(24f, body.height(), 0f);
}
}
@@ -12,9 +12,9 @@ import java.util.Set;
public final class LevelCatalogTest {
@Test
public void productionCatalogContainsFourHazardTeachingLevelsAfterTheVerticalSlice() {
public void productionCatalogContainsHazardsAndTwoCorpseStateTeachingLevels() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(14, levels.size());
assertEquals(16, levels.size());
Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i);
@@ -26,6 +26,9 @@ public final class LevelCatalogTest {
assertTrue(levels.get(11).containsMechanic(LevelDefinition.HazardType.FIRE));
assertTrue(levels.get(12).containsMechanic(LevelDefinition.HazardType.PROJECTILE));
assertTrue(levels.get(13).containsMechanic(LevelDefinition.HazardType.EXPLOSION));
assertTrue(levels.get(14).containsMechanic(LevelDefinition.HazardType.CRUSHER));
assertTrue(levels.get(15).containsMechanic(LevelDefinition.HazardType.FIRE));
assertFalse(levels.get(15).platforms.get(1).controlId.isEmpty());
}
@Test
@@ -8,8 +8,8 @@ import org.junit.Test;
public final class LevelPageTest {
@Test
public void fourteenLevelsUseTwoBoundedTenCardPages() {
LevelPage page = new LevelPage(14, 10);
public void sixteenLevelsUseTwoBoundedTenCardPages() {
LevelPage page = new LevelPage(16, 10);
assertEquals(2, page.pageCount());
assertEquals(0, page.firstIndex());
@@ -19,7 +19,7 @@ public final class LevelPageTest {
page.next();
assertEquals(10, page.firstIndex());
assertEquals(14, page.endExclusive());
assertEquals(16, page.endExclusive());
assertTrue(page.hasPrevious());
assertFalse(page.hasNext());
@@ -27,7 +27,7 @@ public final class LevelPageTest {
assertEquals(1, page.pageIndex());
page.selectIndex(3);
assertEquals(0, page.pageIndex());
page.selectIndex(13);
page.selectIndex(15);
assertEquals(1, page.pageIndex());
}
}
@@ -27,6 +27,8 @@ public final class VersionedSessionCodecTest {
assertEquals(2, restored.bodies().size());
assertTrue(restored.bodies().get(0).pinned());
assertTrue(restored.bodies().get(1).conductive());
assertTrue(restored.bodies().get(0).crushed());
assertTrue(restored.bodies().get(1).burned());
assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes());
assertTrue(restored.toggleSignals().get("gate"));
assertEquals(2.25f, restored.timedSignals().get("clock"), 0f);
@@ -43,6 +45,19 @@ public final class VersionedSessionCodecTest {
decoded.snapshot().saveVersion());
assertTrue(decoded.snapshot().previousSwitchContacts().isEmpty());
assertEquals(2, decoded.snapshot().bodies().size());
assertFalse(decoded.snapshot().bodies().get(0).crushed());
assertFalse(decoded.snapshot().bodies().get(1).burned());
}
@Test
public void legacyV2MigratesWithSafeDefaultCorpseStates() {
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
VersionedSessionCodec.encodeLegacyV2(sampleSnapshot()));
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
assertTrue(decoded.snapshot().previousSwitchContacts().get("gate@420.0"));
assertFalse(decoded.snapshot().bodies().get(0).crushed());
assertFalse(decoded.snapshot().bodies().get(1).burned());
}
@Test
@@ -84,10 +99,12 @@ public final class VersionedSessionCodecTest {
0.1f, 0.08f, -1, -1))
.addBody(new GameSessionSnapshot.BodyData(
500f, 826f, 72f, 54f, 0f, 0f,
false, false, true, true, -1, "SPIKE"))
false, false, true, true, true, false,
-1, "CRUSH"))
.addBody(new GameSessionSnapshot.BodyData(
720f, 826f, 72f, 54f, 0f, 0f,
false, true, true, true, -1, "ELECTRIC"))
false, true, true, true, false, true,
-1, "FIRE"))
.platformMotionTimes(Arrays.asList(1.25f, 3.5f))
.toggleSignal("gate", true)
.timedSignal("clock", 2.25f)
+3 -1
View File
@@ -189,7 +189,9 @@ data-driven модель. Завершён первый mechanics slice: product
(`crusher`, `fire`, `projectile`, `explosion`), обучающие уровни 11–14,
динамическая пагинация каталога и 12/12 bot-прохождений. Последний gate:
99 JVM-тестов и Android 16 matrix `43/43` (`2004.172 s`). Следующая зависимая
итерация — новые corpse states на источниках production hazards.
итерация завершена: `CRUSHED` и `BURNED`, save migration и обучающие уровни
15–16 приняты clean gate `102 JVM` и unfiltered Android matrix `46/46`
(`1,842.248 s`). Доказательства зафиксированы в `STAGE6_CORPSE_STATES.md`.
### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
+48 -3
View File
@@ -1,8 +1,8 @@
# Графы решений уровней 1–10
# Графы решений уровней 1–16
Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения.
На 18 августа 2026 года intended route всех уровней 1–10 автоматически выполнен по три раза подряд без retry masking. `EarlyLevelPlaythroughTest`, `LevelFivePlaythroughTest`, `LateLevelPlaythroughTest` и `LevelEightPlaythroughTest` управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`.
На 19 августа 2026 года intended route всех уровней 1–16 автоматически выполнен по три раза подряд без retry masking. Device-тесты управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`.
## Семантика, общая для графов
@@ -11,7 +11,7 @@
- `TIMED` запускает таймер только на фронте контакта; удержание на переключателе не перезапускает истёкший таймер.
- `Door.requiredSignals` использует AND-семантику: дверь открыта, только когда активны все указанные сигналы.
- Управляемая движущаяся платформа накапливает `motionTime` только при активном `controlId`; при потере сигнала она замирает в текущей фазе `GameMath.pingPong`.
- `SPIKES` оставляет закреплённое тело над шипами; `SAW` передаёт телу `impulseX`/`impulseY`; `FREEZER` создаёт неподвижный блок `104×104`; `ELECTRIC` оставляет закреплённое проводящее тело внутри опасности; `PIT` тела не оставляет.
- `SPIKES` оставляет закреплённое тело над шипами; `SAW` передаёт телу `impulseX`/`impulseY`; `FREEZER` создаёт неподвижный блок `104×104`; `ELECTRIC` оставляет закреплённое проводящее тело внутри опасности; `CRUSHER` создаёт низкое `CRUSHED`-тело; `FIRE` оставляет твёрдое `BURNED`-тело без pressure mass; `PIT` тела не оставляет.
- Тела являются твёрдыми опорами для игрока. Смерть сохраняет тела и состояния уровня, respawn возвращает нового игрока в `spawnX`/`spawnY`; restart очищает состояние.
- Пересечение живого игрока с `goal` переводит сцену в `COMPLETE`.
@@ -360,6 +360,51 @@ Door требует signal "blast-gate" от удалённой PRESSURE
доказал перелёт тела за плиту; минимальное authored значение `550` стабильно
помещает тело в `firstCorpseX=980`, после чего `allDoorsOpen=true`.
## Уровень 15 — `Pressed Into Service`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `1`; сохранённое тело обязано иметь состояние
`CRUSHED`, размер `140×24` и оставаться твёрдой опорой.
```text
Верхний ledge начинается на высоте y=720 и недоступен прыжком с floor
→ первый доброволец входит в активный CRUSHER
→ пресс превращает тело в низкую закреплённую плиту 140×24
→ следующий доброволец наблюдает новый активный цикл и ждёт REST
→ проходит безопасный пресс и приземляется на crushed body
→ отдельным прыжком использует 24 px ступень для достижения ledge
→ проходит по верхней платформе к goal
→ COMPLETE ровно с одной смертью и одним crushed body
```
**Контракт:** `CRUSHED` композиционно сохраняется в `CorpseManager`, уменьшает
коллайдер и не удаляет физическую опору. Save version 3 сохраняет флаг; v1/v2
мигрируют его в `false`. Snapshot отдельно публикует state, размеры и pressure
capability, а Canvas добавляет текстовую метку `CRUSHED` помимо цвета.
## Уровень 16 — `Ashen Counterweight`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `1`; сохранённое тело обязано иметь состояние
`BURNED`, оставаться твёрдым и не активировать `PRESSURE`.
```text
Moving bridge над PIT управляется signal "burn-weight"
→ первый доброволец пересекает PRESSURE и погибает в FIRE над плитой
→ burned body остаётся на плите как твёрдый объект
→ burned body не даёт pressure mass: signal остаётся inactive
→ мост остаётся в нижней фазе у y≈840 вместо подъёма
→ следующий доброволец перепрыгивает FIRE и burned body
→ садится на неподвижный мост и пересекает PIT
→ достигает goal на правом floor
→ COMPLETE ровно с одной смертью и одним burned body
```
**Контракт:** `BURNED` сохраняет коллайдер, но `activatesPressure=false`; это
проверяется одновременно состоянием тела, неактивным `firstMovingPlatformPowered`
и нижней координатой моста. Save version 3 сохраняет флаг; Canvas добавляет
текстовую метку `BURNED`, поэтому состояние не кодируется только цветом.
## Проверки, следующие из графов
1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8.
+64
View File
@@ -0,0 +1,64 @@
# Stage 6 production corpse states
Date: 19 August 2026
Beads task: `goon-game-corpse-states`
## Contract
The accepted `CorpseManager` transformations now include two production states:
- `CRUSHED`: a pinned `140×24` solid body whose changed collider creates a low
step without becoming a full-height wall;
- `BURNED`: a solid retained body that no longer contributes pressure mass.
Both transformations compose with existing body ownership and pinning, have
distinct non-color Canvas labels, are exposed through read-only automation, and
are serialized in active-session save version 3. Legacy version 1 and version 2
sessions migrate both flags to `false`; progress save versioning is unchanged.
This is the complete set of new gameplay states approved by the GDD. `IsDestroyed`
is a lifecycle field in the corpse data model, not a listed corpse-state variant;
the plan does not define a separate `Shattered` state.
Level 15 teaches the collider difference by requiring the crushed slab to reach
an otherwise inaccessible ledge. Level 16 teaches the mechanism difference by
requiring a burned body to remain on a pressure plate without powering the
bridge above a pit. Normal, pinned, frozen and conductive states retain their
accepted teaching routes in levels 2/3/5, 6 and 7/9/10 respectively.
## TDD evidence
Focused RED cycles covered the missing transformations and behavior, legacy
codec migration, GameView state publication, catalog growth from 14 to 16, and
both production-input scenarios. Device RED on level 16 showed a small bridge
offset caused by the living volunteer touching the plate before death; the
acceptance was corrected to prove the authored behavior (`powered=false` and a
lower bridge) without requiring an impossible exact initial coordinate.
The focused runtime integration test creates crushed and burned bodies through
real level hazards. Each playthrough test runs its intended route three times,
with no restart or direct game-state mutation, and requires exactly one death
and one retained body.
## Acceptance gates
- JSON validator: `16/16`, format `1`, physics `1`.
- Level 15: `3/3` GREEN in `77.563 s`.
- Level 16: `3/3` GREEN in `32.015 s` after the state-based bridge assertion.
- Runtime state integration: `1/1` GREEN in `1.165 s`.
- Combined corpse-state/hazard/pagination matrix: `8/8` methods GREEN in
`408.438 s` (18 production-input playthroughs plus state and pagination tests).
- Clean gate: `102` JVM tests, failures/errors/skipped `0`; `lintDebug`,
`assembleDebug` and `assembleDebugAndroidTest` GREEN.
- Focused exact-candidate save/restore plus runtime state gate: `3/3` GREEN in
`47.489 s`.
- Final unfiltered Android 16 matrix on the exact clean APK/test APK: `46/46`,
failures `0`, errors `0`, skipped `0`, `1,842.248 s`.
- Debug APK ZIP integrity: GREEN; packaged JSON levels: `16`.
- Package: `com.projectsacrifice.game.debug`, versionCode `9`, minSdk `23`,
targetSdk `34`; APK Signature Scheme v1/v2 verification: GREEN.
- Debug APK SHA-256:
`4984f50aa62203060eb254b69dbf2bdf520ab6cd545eff852d22b10a164bf686`.
The dedicated AVD was stopped after the final gate.
+1 -1
View File
@@ -65,7 +65,7 @@ Snapshot на 19 августа 2026 года:
| 3 | 5 | 0 |
| 4 | 4 | 0 |
| 5 | 15 | 0 |
| 6 | 18 | 12 |
| 6 | 18 | 10 |
| 7 | 9 | 9 |
| 8 | 1 | 1 |
| 9 | 1 | 1 |
@@ -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"]}
{"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"]}
@@ -0,0 +1,14 @@
{
"formatVersion":1,"physicsVersion":1,"number":15,
"copy":{"en":{"title":"Pressed Into Service","hint":"The press leaves a low step. Use its thin profile to reach the ledge."},"ru":{"title":"Служба под прессом","hint":"Пресс оставляет низкую ступень. Используйте её профиль, чтобы достичь уступа."}},
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2180,"y":610,"width":86,"height":110},"targets":{"deaths":1,"seconds":40},
"platforms":[
{"box":{"x":0,"y":880,"width":2400,"height":200}},
{"box":{"x":850,"y":720,"width":1550,"height":160}}
],
"hazards":[{"type":"CRUSHER","box":{"x":700,"y":690,"width":140,"height":190},
"impulseX":0,"impulseY":0,"cycleSeconds":4,"activeSeconds":2.5,
"speedX":0,"speedY":0,"visualStyle":"brass-press","audioCue":"crusher-impact"}],
"switches":[],"doors":[]
}
@@ -0,0 +1,22 @@
{
"formatVersion":1,"physicsVersion":1,"number":16,
"copy":{"en":{"title":"Ashen Counterweight","hint":"Ash remains solid but cannot wake the bridge. Use that difference."},"ru":{"title":"Пепельный противовес","hint":"Пепел остаётся твёрдым, но не запускает мост. Используйте это различие."}},
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2200,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":45},
"platforms":[
{"box":{"x":0,"y":880,"width":1000,"height":200}},
{"box":{"x":1000,"y":840,"width":400,"height":40},"travelX":0,"travelY":-300,"periodSeconds":5,"controlId":"burn-weight"},
{"box":{"x":1400,"y":880,"width":1000,"height":200}}
],
"hazards":[
{"type":"FIRE","box":{"x":700,"y":790,"width":120,"height":90},
"impulseX":0,"impulseY":0,"cycleSeconds":0,"activeSeconds":0,
"speedX":0,"speedY":0,"visualStyle":"blue-flame","audioCue":"fire-burst"},
{"type":"PIT","box":{"x":1000,"y":880,"width":400,"height":200},
"impulseX":0,"impulseY":0,"cycleSeconds":0,"activeSeconds":0,
"speedX":0,"speedY":0,"visualStyle":"","audioCue":""}
],
"switches":[{"type":"PRESSURE","box":{"x":610,"y":860,"width":180,"height":20},
"signalId":"burn-weight","durationSeconds":0,"requiredMass":1}],
"doors":[]
}