From cc3b5902cc59fc0f7a410ec9f50f0a9d8111a323 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Wed, 19 Aug 2026 03:43:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20CorpseManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/issues.jsonl | 4 +- README.md | 10 +- .../game/CorpseManagerIntegrationTest.java | 74 ++++++++ .../projectsacrifice/game/CorpseManager.java | 172 ++++++++++++++++++ .../com/projectsacrifice/game/GameView.java | 114 ++++++------ .../game/CorpseManagerTest.java | 71 ++++++++ docs/CURRENT_ROADMAP.md | 10 +- docs/STAGE6_CORPSE_MANAGER.md | 50 +++++ 8 files changed, 436 insertions(+), 69 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/CorpseManagerIntegrationTest.java create mode 100644 app/src/main/java/com/projectsacrifice/game/CorpseManager.java create mode 100644 app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java create mode 100644 docs/STAGE6_CORPSE_MANAGER.md diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fd81ea0..a7cfa0b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -49,10 +49,10 @@ {"_type":"issue","id":"goon-game-ui-vslice","title":"Базовый HUD и multitouch controls","description":"В APK есть направления, jump, restart, pause, deaths и timer.","acceptance_criteria":"Управление работает одновременно двумя касаниями.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:21Z","close_reason":"Базовый HUD и multitouch controls реализованы.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-ui-vslice","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:22Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-camera-polish","title":"Доработать production camera controller","description":"Добавить vertical damping, room transitions, focus target и управляемый shake.","acceptance_criteria":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","notes":"Stage 5 RC: bounded horizontal/vertical damping, mechanism focus, impact shake, reduced-motion persistence; camera excluded from physics signatures; 35/35 Android gate GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:50Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:45Z","closed_at":"2026-08-18T23:01:45Z","close_reason":"Production camera behavior and reduced-motion acceptance are implemented and regression-tested.","metadata":{"stages":5,"primary_stage":5},"labels":["camera","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-camera-polish","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:16Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-camera-basic","title":"Базовая камера с look-ahead и bounds","description":"В текущем runtime реализовано сглаженное горизонтальное слежение.","acceptance_criteria":"Камера работает во всех 10 уровнях.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:18Z","close_reason":"Базовое сглаженное слежение реализовано.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-camera-basic","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:15Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"goon-game-puzzle-events","title":"Выделить event-driven PuzzleSystem","description":"Заменить прямую проверку сигналов универсальными каналами событий и явными состояниями DoorController.","acceptance_criteria":"Кнопки, двери и платформы связываются данными; integration-тесты проходят.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","primary-stage-6","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-puzzle-events","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:13Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"goon-game-puzzle-events","title":"Выделить event-driven PuzzleSystem","description":"Заменить прямую проверку сигналов универсальными каналами событий и явными состояниями DoorController.","acceptance_criteria":"Кнопки, двери и платформы связываются данными; integration-тесты проходят.","notes":"2026-08-19: claimed as the next Stage 6 foundation slice after CorpseManager. Planned boundary: deterministic event/signal/door state model, data-driven bindings, GameView adapter, RED-GREEN integration, no level-solution changes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:43:02Z","started_at":"2026-08-19T00:43:02Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","primary-stage-6","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-puzzle-events","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:13Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-puzzle-vslice","title":"Базовые puzzle objects vertical slice","description":"Работают pressure/toggle/timed switches, doors и moving platforms.","acceptance_criteria":"Существующие уровни используют все базовые типы.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:39Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:17Z","close_reason":"Базовые кнопки, двери и платформы реализованы.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-puzzle-vslice","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:11Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-hazards-vslice","title":"Опасности vertical slice","description":"Реализованы spikes, saw, pit, freeze и electricity.","acceptance_criteria":"Опасности присутствуют в каталоге 10 уровней и не ломают restart.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:35Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:16Z","close_reason":"Базовый набор опасностей присутствует в 10 уровнях.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-hazards-vslice","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"goon-game-corpse-manager","title":"Выделить полноценный CorpseManager","description":"Убрать управление телами из монолитного GameView; добавить register/remove/transform/reset, лимит и диагностические счётчики.","acceptance_criteria":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","notes":"2026-08-19: next Stage 6 foundation slice after character state machine. Planned boundary: deep public corpse lifecycle contract, GameView extraction without level-solution changes, test-first register/remove/transform/reset/limit/diagnostics.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:19:59Z","started_at":"2026-08-19T00:19:59Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","corpse","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-manager","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:04Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"goon-game-corpse-manager","title":"Выделить полноценный CorpseManager","description":"Убрать управление телами из монолитного GameView; добавить register/remove/transform/reset, лимит и диагностические счётчики.","acceptance_criteria":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","notes":"2026-08-19: next Stage 6 foundation slice after character state machine. Planned boundary: deep public corpse lifecycle contract, GameView extraction without level-solution changes, test-first register/remove/transform/reset/limit/diagnostics.\n2026-08-19: completed test-first. CorpseManager now owns bounded register/remove/reset/FIFO eviction, composable pinned/frozen/conductive transformations, body save/restore and immutable diagnostics. GameView remains the physics adapter; no physics, geometry or level-signature changes. Final gates: 84 JVM tests; lintDebug/assembleDebug/assembleDebugAndroidTest GREEN; Android 16 unfiltered 37/37, 0 failed/0 skipped, 427.237s; APK ZIP valid, SHA-256 982fcf312f10bf23feb3a8794b708b61002012a3706c94ebb4320fefb36fb889. Evidence: docs/STAGE6_CORPSE_MANAGER.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:43:01Z","started_at":"2026-08-19T00:19:59Z","closed_at":"2026-08-19T00:43:01Z","close_reason":"Acceptance criteria выполнены: отдельный lifecycle-контракт интегрирован, существующие решения сохранены, полная Android-матрица 37/37 зелёная","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","corpse","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-manager","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:04Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-character-states","title":"Завершить state machine персонажа","description":"Добавить явные Interact, Stunned, Turn, Land и animation-state переходы; вынести coyote/input buffer в конфиг.","acceptance_criteria":"Все переходы детерминированы, визуально различимы и имеют тесты.","notes":"2026-08-19: Stage 6 started after accepted Vertical Slice. First vertical slice: explicit deterministic character state machine (Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death), movement timings sourced from PhysicsConfig, GameView integration without physics changes, public automation state, JVM contract and full build gate.\n2026-08-19: TDD complete. Explicit Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death model, configured timings, non-color poses, GameView spawn/update/death/restore/switch/automation integration. Final exact gates: 81 JVM; lint/build; Android 16 unfiltered 36/36 in 720.578s; APK ZIP and SHA d25b7dd0a06cead0d514539773acd0a84ed92bf7c2510b9b880ded2f66508581. Evidence: docs/STAGE6_CHARACTER_STATES.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:20Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:19:57Z","started_at":"2026-08-18T23:20:45Z","closed_at":"2026-08-19T00:19:57Z","close_reason":"All character transitions are deterministic, visually distinct, configured and proven by JVM plus exact Android 16 integration/regression gates.","metadata":{"stages":6,"primary_stage":6},"labels":["character","future","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-character-states","depends_on_id":"goon-game-e01","type":"parent-child","created_at":"2026-08-18T00:43:02Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-character-basic","title":"Базовый character controller","description":"Движение, остановка, разворот, прыжок, падение и приземление в текущем Canvas runtime.","acceptance_criteria":"Функции присутствуют в APK и покрыты smoke-тестом.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:18Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:12Z","close_reason":"Реализовано в текущем vertical slice.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-character-basic","depends_on_id":"goon-game-e01","type":"parent-child","created_at":"2026-08-18T00:43:01Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e13","title":"EPIC-13 Optimization","description":"60 FPS target, object pooling, physics budgets и low profile. Разделы 41–44.","acceptance_criteria":"Производительность укладывается в зафиксированные бюджеты на средних и слабых устройствах.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:10Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:51Z","metadata":{"stages":"5,7","primary_stage":7},"labels":["epic","performance","primary-stage-7","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e13","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:56Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index 73454b8..8c691d1 100644 --- a/README.md +++ b/README.md @@ -105,10 +105,12 @@ times with identical state signatures. The product owner confirmed completion of external testing on 19 August 2026; the evidence boundary is recorded without invented session data in `docs/STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 5 is accepted. -Stage 6 production work has started. The first completed foundation slice adds a -deterministic character state machine with explicit transient and command states; -scope and final `81 JVM / 36 Android` evidence are in -`docs/STAGE6_CHARACTER_STATES.md`. +Stage 6 production work has started. Its first two completed foundation slices add +a deterministic character state machine and a bounded `CorpseManager` that owns +body registration, transformations, eviction, reset, save and restore. Scope and +final evidence are in `docs/STAGE6_CHARACTER_STATES.md` and +`docs/STAGE6_CORPSE_MANAGER.md`; the latest unfiltered gate is +`84 JVM / 37 Android`. 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/CorpseManagerIntegrationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/CorpseManagerIntegrationTest.java new file mode 100644 index 0000000..70282c6 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/CorpseManagerIntegrationTest.java @@ -0,0 +1,74 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.app.Instrumentation; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class CorpseManagerIntegrationTest { + @Test + public void deathRegistersAndRestartResetsTheOwnedBodyLifecycle() throws Exception { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + try (ActivityScenario activity = + ActivityScenario.launch(MainActivity.class)) { + GameView[] view = new GameView[1]; + activity.onActivity(current -> view[0] = current.gameViewForAutomation()); + instrumentation.runOnMainSync(() -> { + view[0].startLevelForAutomation(10); + view[0].setAutomationInput(false, true, false); + }); + + GameView.AutomationSnapshot died = awaitBody(instrumentation, view[0], 8_000L); + CorpseManager.Diagnostics before = diagnostics(instrumentation, view[0]); + assertEquals(1, died.corpseCount()); + assertEquals(1L, before.registered()); + assertEquals(0L, before.evicted()); + + instrumentation.runOnMainSync(view[0]::restartLevelForAutomation); + GameView.AutomationSnapshot restarted = snapshot(instrumentation, view[0]); + CorpseManager.Diagnostics after = diagnostics(instrumentation, view[0]); + + assertEquals(0, restarted.corpseCount()); + assertEquals(before.resets() + 1L, after.resets()); + assertEquals(before.clearedByReset() + 1L, after.clearedByReset()); + assertTrue(restarted.playerAlive()); + } + } + + private static GameView.AutomationSnapshot awaitBody( + Instrumentation instrumentation, GameView view, long timeoutMillis) + throws Exception { + long deadline = System.currentTimeMillis() + timeoutMillis; + GameView.AutomationSnapshot current = snapshot(instrumentation, view); + while (System.currentTimeMillis() < deadline) { + current = snapshot(instrumentation, view); + if (current.corpseCount() == 1) return current; + Thread.sleep(24L); + } + throw new AssertionError("Body was not registered; deaths=" + current.deaths() + + ", corpses=" + current.corpseCount()); + } + + private static GameView.AutomationSnapshot snapshot( + Instrumentation instrumentation, GameView view) { + GameView.AutomationSnapshot[] result = new GameView.AutomationSnapshot[1]; + instrumentation.runOnMainSync(() -> result[0] = view.automationSnapshot()); + return result[0]; + } + + private static CorpseManager.Diagnostics diagnostics( + Instrumentation instrumentation, GameView view) { + CorpseManager.Diagnostics[] result = new CorpseManager.Diagnostics[1]; + instrumentation.runOnMainSync( + () -> result[0] = view.corpseDiagnosticsForAutomation()); + return result[0]; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/CorpseManager.java b/app/src/main/java/com/projectsacrifice/game/CorpseManager.java new file mode 100644 index 0000000..567cf9e --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/CorpseManager.java @@ -0,0 +1,172 @@ +package com.projectsacrifice.game; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Owns the bounded lifecycle of puzzle bodies for one active level. */ +public final class CorpseManager { + public enum Transformation { + PINNED, + FROZEN, + CONDUCTIVE + } + + public static final class Body { + final long id; + final String deathType; + float x; + float y; + float width = 72f; + float height = 54f; + float vx; + float vy; + boolean frozen; + boolean conductive; + boolean pinned; + boolean grounded; + int supportIndex = -1; + + private Body(long id, String deathType, float x, float y) { + this.id = id; + this.deathType = deathType; + this.x = x; + this.y = y; + } + + public long id() { return id; } + public String deathType() { return deathType; } + public float x() { return x; } + public float y() { return y; } + public float width() { return width; } + public float height() { return height; } + public float velocityX() { return vx; } + public float velocityY() { return vy; } + public boolean frozen() { return frozen; } + public boolean conductive() { return conductive; } + public boolean pinned() { return pinned; } + public boolean grounded() { return grounded; } + public int supportIndex() { return supportIndex; } + + public Body position(float x, float y) { + this.x = x; + this.y = y; + return this; + } + + public Body size(float width, float height) { + if (width <= 0f || height <= 0f) { + throw new IllegalArgumentException("body size must be positive"); + } + this.width = width; + this.height = height; + return this; + } + + public Body velocity(float x, float y) { + vx = x; + vy = y; + return this; + } + + public Body grounded(boolean value) { + grounded = value; + return this; + } + + public Body supportIndex(int value) { + supportIndex = value; + return this; + } + } + + public static final class Diagnostics { + private final long registered; + private final long evicted; + private final long removed; + private final long resets; + private final long clearedByReset; + + private Diagnostics(long registered, long evicted, long removed, + long resets, long clearedByReset) { + this.registered = registered; + this.evicted = evicted; + this.removed = removed; + this.resets = resets; + this.clearedByReset = clearedByReset; + } + + public long registered() { return registered; } + public long evicted() { return evicted; } + public long removed() { return removed; } + public long resets() { return resets; } + public long clearedByReset() { return clearedByReset; } + } + + private final int maximumBodies; + private final ArrayList bodies = new ArrayList<>(); + private long nextId = 1L; + private long registered; + private long evicted; + private long removed; + private long resets; + private long clearedByReset; + + public CorpseManager(int maximumBodies) { + if (maximumBodies < 1) throw new IllegalArgumentException("maximumBodies must be positive"); + this.maximumBodies = maximumBodies; + } + + public Body register(String deathType, float x, float y) { + Body body = new Body(nextId++, safeDeathType(deathType), x, y); + bodies.add(body); + registered++; + if (bodies.size() > maximumBodies) { + bodies.remove(0); + evicted++; + } + return body; + } + + public int count() { return bodies.size(); } + + public boolean remove(Body body) { + boolean changed = body != null && bodies.remove(body); + if (changed) removed++; + return changed; + } + + public boolean transform(Body body, Transformation transformation) { + if (body == null || transformation == null || !bodies.contains(body)) return false; + switch (transformation) { + case PINNED: + body.pinned = true; + break; + case FROZEN: + body.frozen = true; + break; + case CONDUCTIVE: + body.conductive = true; + break; + } + return true; + } + + public void reset() { + clearedByReset += bodies.size(); + bodies.clear(); + resets++; + } + + public List bodies() { + return Collections.unmodifiableList(bodies); + } + + public Diagnostics diagnostics() { + return new Diagnostics(registered, evicted, removed, resets, clearedByReset); + } + + private static String safeDeathType(String value) { + return value == null || value.trim().isEmpty() ? "GENERIC" : value; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 88d893d..c4e733e 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -1,5 +1,7 @@ package com.projectsacrifice.game; +import com.projectsacrifice.game.CorpseManager.Body; + import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; @@ -190,18 +192,6 @@ public final class GameView extends View implements Runnable { int facing = 1; } - private static final class Corpse { - float x, y, width = 72f, height = 54f, vx, vy; - boolean frozen; - boolean conductive; - boolean pinned; - boolean grounded; - int supportIndex = -1; - DeathType type; - - RectF rect() { return new RectF(x, y, x + width, y + height); } - } - private static final class PlatformState { float x, y, previousX, previousY, motionTime; final LevelDefinition.Platform definition; @@ -235,7 +225,8 @@ public final class GameView extends View implements Runnable { private final Player player = new Player(); private final CharacterStateMachine characterStateMachine = new CharacterStateMachine(PHYSICS); - private final ArrayList corpses = new ArrayList<>(); + private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES); + private final List corpses = corpseManager.bodies(); private final ArrayList platformStates = new ArrayList<>(); private final Map toggleSignals = new HashMap<>(); private final Map timedSignals = new HashMap<>(); @@ -400,7 +391,7 @@ public final class GameView extends View implements Runnable { userPaused = false; pointers.clear(); deriveControls(); - corpses.clear(); + corpseManager.reset(); platformStates.clear(); toggleSignals.clear(); timedSignals.clear(); @@ -517,7 +508,7 @@ 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++; - for (Corpse corpse : corpses) { + for (Body corpse : corpses) { if (intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) mass++; } return mass >= item.requiredMass; @@ -533,7 +524,7 @@ public final class GameView extends View implements Runnable { } for (LevelDefinition.Hazard hazard : level.hazards) { if (id.equals(hazard.relayId)) { - for (Corpse corpse : corpses) { + for (Body corpse : corpses) { if (corpse.conductive && intersects(corpse.x, corpse.y, corpse.width, corpse.height, hazard.box)) return true; } } @@ -566,7 +557,7 @@ public final class GameView extends View implements Runnable { player.x += support.x - support.previousX; player.y += support.y - support.previousY; } - for (Corpse corpse : corpses) { + for (Body corpse : corpses) { if (!corpse.frozen && corpse.supportIndex >= 0 && corpse.supportIndex < platformStates.size()) { PlatformState support = platformStates.get(corpse.supportIndex); corpse.x += support.x - support.previousX; @@ -650,8 +641,8 @@ public final class GameView extends View implements Runnable { player.vy = 0f; current = playerRect(); } - for (Corpse corpse : corpses) { - RectF solid = corpse.rect(); + for (Body corpse : corpses) { + RectF solid = corpseRect(corpse); if (!RectF.intersects(current, solid)) continue; if (amount >= 0f) { player.y = solid.top - PLAYER_HEIGHT; @@ -666,7 +657,7 @@ public final class GameView extends View implements Runnable { } private void updateCorpses(float dt) { - for (Corpse corpse : corpses) { + for (Body corpse : corpses) { if (corpse.frozen || corpse.pinned) continue; corpse.vy += GRAVITY * dt; corpse.grounded = false; @@ -680,7 +671,7 @@ public final class GameView extends View implements Runnable { } } - private boolean settleCorpseOnPressurePlate(Corpse corpse) { + private boolean settleCorpseOnPressurePlate(Body corpse) { for (LevelDefinition.Switch item : level.switches) { if (item.type != LevelDefinition.SwitchType.PRESSURE) continue; if (!intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) continue; @@ -691,26 +682,26 @@ public final class GameView extends View implements Runnable { } corpse.vx = 0f; corpse.vy = 0f; - corpse.pinned = true; + corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); return true; } return false; } - private void resolveCorpseHorizontal(Corpse corpse) { - RectF current = corpse.rect(); + private void resolveCorpseHorizontal(Body corpse) { + RectF current = corpseRect(corpse); for (RectF solid : solidRects(false)) { if (!RectF.intersects(current, solid)) continue; if (corpse.vx > 0f) corpse.x = solid.left - corpse.width; else if (corpse.vx < 0f) corpse.x = solid.right; corpse.vx *= -0.18f; - current = corpse.rect(); + current = corpseRect(corpse); } } - private void resolveCorpseVertical(Corpse corpse) { + private void resolveCorpseVertical(Body corpse) { corpse.supportIndex = -1; - RectF current = corpse.rect(); + RectF current = corpseRect(corpse); for (int i = 0; i < platformStates.size(); i++) { RectF solid = platformStates.get(i).rect(); if (!RectF.intersects(current, solid)) continue; @@ -723,7 +714,7 @@ public final class GameView extends View implements Runnable { corpse.y = solid.bottom; corpse.vy *= -0.1f; } - current = corpse.rect(); + current = corpseRect(corpse); } for (LevelDefinition.Door door : level.doors) { if (doorOpen(door)) continue; @@ -737,7 +728,7 @@ public final class GameView extends View implements Runnable { corpse.y = solid.bottom; corpse.vy *= -0.1f; } - current = corpse.rect(); + current = corpseRect(corpse); } } @@ -745,7 +736,7 @@ public final class GameView extends View implements Runnable { ArrayList result = new ArrayList<>(); 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 (Corpse corpse : corpses) result.add(corpse.rect()); + if (includeCorpses) for (Body corpse : corpses) result.add(corpseRect(corpse)); return result; } @@ -778,10 +769,8 @@ public final class GameView extends View implements Runnable { cameraController.impact(type == DeathType.PIT ? 12f : 24f); if (type != DeathType.PIT) { - Corpse corpse = new Corpse(); - corpse.type = type; - corpse.x = player.x; - corpse.y = player.y + PLAYER_HEIGHT - corpse.height; + Body corpse = corpseManager.register(type.name(), player.x, + player.y + PLAYER_HEIGHT - 54f); if (source != null) { corpse.vx = source.impulseX; corpse.vy = source.impulseY; @@ -789,23 +778,21 @@ public final class GameView extends View implements Runnable { if (type == DeathType.SPIKE && source != null) { corpse.y = source.box.y - corpse.height; corpse.vx = corpse.vy = 0f; - corpse.pinned = true; + corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); } else if (type == DeathType.FREEZE && source != null) { corpse.width = 104f; corpse.height = 104f; corpse.x = source.box.x - 2f; corpse.y = 880f - corpse.height; corpse.vx = corpse.vy = 0f; - corpse.frozen = true; + corpseManager.transform(corpse, CorpseManager.Transformation.FROZEN); } else if (type == DeathType.ELECTRIC && source != null) { - corpse.conductive = true; corpse.x = source.box.x + (source.box.width - corpse.width) * 0.5f; corpse.y = 880f - corpse.height; corpse.vx = corpse.vy = 0f; - corpse.pinned = true; + corpseManager.transform(corpse, CorpseManager.Transformation.CONDUCTIVE); + corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); } - corpses.add(corpse); - while (corpses.size() > MAX_CORPSES) corpses.remove(0); } } @@ -841,6 +828,11 @@ public final class GameView extends View implements Runnable { return new RectF(player.x, player.y, player.x + PLAYER_WIDTH, player.y + PLAYER_HEIGHT); } + private static RectF corpseRect(Body corpse) { + return new RectF(corpse.x, corpse.y, + corpse.x + corpse.width, corpse.y + corpse.height); + } + private static RectF rect(LevelDefinition.Box box) { return new RectF(box.x, box.y, box.right(), box.bottom()); } @@ -902,7 +894,7 @@ public final class GameView extends View implements Runnable { drawHazards(canvas); drawDoors(canvas); drawGoal(canvas); - for (Corpse corpse : corpses) drawCorpse(canvas, corpse); + for (Body corpse : corpses) drawCorpse(canvas, corpse); VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number); if (player.alive) { VolunteerRig.Motion motion = VolunteerRig.motionFor(characterStateMachine.state()); @@ -1140,7 +1132,7 @@ public final class GameView extends View implements Runnable { canvas.drawCircle(box.centerX() - 6f, box.centerY() - 7f, 7f, paint); } - private void drawCorpse(Canvas canvas, Corpse corpse) { + private void drawCorpse(Canvas canvas, Body corpse) { canvas.save(); float cx = corpse.x + corpse.width * 0.5f; float cy = corpse.y + corpse.height * 0.5f; @@ -2280,12 +2272,12 @@ public final class GameView extends View implements Runnable { player.x, player.y, player.vx, player.vy, player.alive, player.onGround, player.coyote, player.jumpBuffer, player.supportIndex, player.facing)); - for (Corpse corpse : corpses) { + for (Body corpse : corpses) { 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.type == null ? DeathType.SPIKE.name() : corpse.type.name())); + corpse.deathType())); } ArrayList motionTimes = new ArrayList<>(platformStates.size()); for (PlatformState state : platformStates) motionTimes.add(state.motionTime); @@ -2353,22 +2345,22 @@ public final class GameView extends View implements Runnable { characterStateMachine.reset(player.alive, player.onGround, player.vx, player.vy, player.facing); - corpses.clear(); for (GameSessionSnapshot.BodyData savedBody : snapshot.bodies()) { - Corpse corpse = new Corpse(); - corpse.x = savedBody.x(); - corpse.y = savedBody.y(); - corpse.width = savedBody.width(); - corpse.height = savedBody.height(); - corpse.vx = savedBody.velocityX(); - corpse.vy = savedBody.velocityY(); - corpse.frozen = savedBody.frozen(); - corpse.conductive = savedBody.conductive(); - corpse.pinned = savedBody.pinned(); - corpse.grounded = savedBody.grounded(); - corpse.supportIndex = savedBody.supportIndex(); - corpse.type = DeathType.valueOf(savedBody.deathType()); - corpses.add(corpse); + Body corpse = corpseManager.register(savedBody.deathType(), + savedBody.x(), savedBody.y()) + .size(savedBody.width(), savedBody.height()) + .velocity(savedBody.velocityX(), savedBody.velocityY()) + .grounded(savedBody.grounded()) + .supportIndex(savedBody.supportIndex()); + if (savedBody.frozen()) { + corpseManager.transform(corpse, CorpseManager.Transformation.FROZEN); + } + if (savedBody.conductive()) { + corpseManager.transform(corpse, CorpseManager.Transformation.CONDUCTIVE); + } + if (savedBody.pinned()) { + corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); + } } leftPressed = false; @@ -2452,4 +2444,8 @@ public final class GameView extends View implements Runnable { public void triggerStunForAutomation() { characterStateMachine.stun(); } + + public CorpseManager.Diagnostics corpseDiagnosticsForAutomation() { + return corpseManager.diagnostics(); + } } diff --git a/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java b/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java new file mode 100644 index 0000000..ff1f607 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/CorpseManagerTest.java @@ -0,0 +1,71 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public final class CorpseManagerTest { + @Test + public void registeringBeyondTheBudgetEvictsTheOldestBodyAndReportsIt() { + CorpseManager manager = new CorpseManager(2); + CorpseManager.Body first = manager.register("SPIKE", 10f, 20f); + CorpseManager.Body second = manager.register("SAW", 30f, 40f); + CorpseManager.Body third = manager.register("FREEZE", 50f, 60f); + + assertEquals(2, manager.count()); + assertSame(second, manager.bodies().get(0)); + assertSame(third, manager.bodies().get(1)); + assertEquals(3L, manager.diagnostics().registered()); + assertEquals(1L, manager.diagnostics().evicted()); + assertEquals(first.id() + 1L, second.id()); + } + + @Test + public void removeAndResetAreIdempotentAndHaveSeparateDiagnostics() { + CorpseManager manager = new CorpseManager(3); + CorpseManager.Body first = manager.register("SPIKE", 10f, 20f); + manager.register("SAW", 30f, 40f); + + assertTrue(manager.remove(first)); + assertFalse(manager.remove(first)); + manager.reset(); + + assertEquals(0, manager.count()); + assertEquals(1L, manager.diagnostics().removed()); + assertEquals(1L, manager.diagnostics().resets()); + assertEquals(1L, manager.diagnostics().clearedByReset()); + } + + @Test + public void ownedBodiesExposePhysicsFactsAndComposableTransformations() { + CorpseManager manager = new CorpseManager(3); + CorpseManager.Body body = manager.register("ELECTRIC", 10f, 20f) + .position(30f, 40f) + .size(104f, 54f) + .velocity(12f, -8f) + .grounded(true) + .supportIndex(2); + + assertTrue(manager.transform(body, CorpseManager.Transformation.FROZEN)); + assertTrue(manager.transform(body, CorpseManager.Transformation.CONDUCTIVE)); + assertTrue(manager.transform(body, CorpseManager.Transformation.PINNED)); + + assertEquals(30f, body.x(), 0f); + assertEquals(40f, body.y(), 0f); + assertEquals(104f, body.width(), 0f); + assertEquals(54f, body.height(), 0f); + assertEquals(12f, body.velocityX(), 0f); + assertEquals(-8f, body.velocityY(), 0f); + assertTrue(body.frozen()); + assertTrue(body.conductive()); + assertTrue(body.pinned()); + assertTrue(body.grounded()); + assertEquals(2, body.supportIndex()); + + CorpseManager.Body foreign = new CorpseManager(1).register("SAW", 0f, 0f); + assertFalse(manager.transform(foreign, CorpseManager.Transformation.FROZEN)); + } +} diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index dec056b..476f32c 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -179,10 +179,12 @@ baseline-задачи также имели явного владельца по - принят `goon-game-milestone-production`. Статус на 19 августа 2026: Stage 6 начат в изолированном production stream. -Первый foundation slice `goon-game-character-states` завершён: явные -Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions, configured -timings и отдельные non-color animation poses прошли 81 JVM-тест и финальную -Android 16 matrix `36/36`. Следующий foundation slice — `CorpseManager`. +Завершены первые два foundation slice. `goon-game-character-states` добавил +явные Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions. +`goon-game-corpse-manager` перенёс регистрацию, transforms, FIFO budget, reset, +save/restore и diagnostics тел в отдельного владельца без изменения физики и +геометрии. Последний gate: 84 JVM-теста и Android 16 matrix `37/37`. +Следующий foundation slice — event-driven `PuzzleSystem`. ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней diff --git a/docs/STAGE6_CORPSE_MANAGER.md b/docs/STAGE6_CORPSE_MANAGER.md new file mode 100644 index 0000000..c4eef82 --- /dev/null +++ b/docs/STAGE6_CORPSE_MANAGER.md @@ -0,0 +1,50 @@ +# Stage 6 CorpseManager + +Date: 19 August 2026 + +Beads task: `goon-game-corpse-manager` + +## Contract + +`CorpseManager` is the single owner of bodies in one active level. It provides: + +- bounded registration with stable IDs and deterministic FIFO eviction; +- explicit remove and level-reset lifecycle operations; +- composable `PINNED`, `FROZEN`, and `CONDUCTIVE` transformations; +- body physics facts needed by collision, moving-platform, save, and restore code; +- immutable lifecycle diagnostics for automation and regression tests. + +`GameView` remains the high-frequency physics adapter, but it no longer creates, +owns, evicts, resets, or restores bodies through an internal collection. The +manager exposes an unmodifiable live view, so callers can update owned body facts +without being able to change collection membership. + +The body budget and FIFO policy preserve the accepted Vertical Slice behavior. +Restart and level changes clear active bodies through the manager; save/restore +re-registers bodies and then reapplies their stored transformations. No level +geometry, movement, collision constant, or death signature changed. + +## TDD evidence + +Separate RED cycles covered the missing manager contract, removal/reset +diagnostics, mutable physics facts plus composable transformations, rejection of +foreign bodies, and the missing `GameView` automation hook. Each RED was followed +by its focused GREEN before the next behavior was introduced. + +The Android integration test creates a real level-10 saw body through production +input, verifies manager registration, restarts the level through the public game +surface, and verifies that manager reset owns the cleanup. + +## Final gates + +- JSON validator: `10/10`, format `1`, physics `1`. +- JVM: `84`, failures `0`, errors `0`, skipped `0`. +- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN. +- focused Android 16 lifecycle: `1/1` GREEN. +- final unfiltered Android 16 matrix: `37/37`, failures `0`, errors `0`, + skipped `0`, `427.237 s`, on the exact final APK/test APK. +- debug APK ZIP integrity: `unzip -t` GREEN. +- debug APK SHA-256: + `982fcf312f10bf23feb3a8794b708b61002012a3706c94ebb4320fefb36fb889`. + +The dedicated AVD was stopped after the final gate.