Compare commits

...
11 Commits
18 changed files with 1229 additions and 102 deletions
+4
View File
@@ -17,3 +17,7 @@
{"id":"int-b5f69bfd","kind":"field_change","created_at":"2026-08-17T21:41:29.38583Z","actor":"E.Gavrilov","issue_id":"goon-game-milestone-vslice","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
{"id":"int-78cceca7","kind":"field_change","created_at":"2026-08-17T22:09:00.917089Z","actor":"E.Gavrilov","issue_id":"goon-game-bot-framework","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Декларативный Scenario runner, именованные шаги, per-step timeout и observable diagnostics реализованы; 11 JVM-тестов, lint, instrumentation compile и connected level 5 GREEN на Android 16."}}
{"id":"int-70d3a443","kind":"field_change","created_at":"2026-08-17T22:09:09.35446Z","actor":"E.Gavrilov","issue_id":"goon-game-solution-graphs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Документированы 10 source-backed solution graphs с механиками, смертями, checkpoints и рисками; только уровень 5 честно отмечен доказанным connected-тестом."}}
{"id":"int-f061141a","kind":"field_change","created_at":"2026-08-18T01:30:33.988847Z","actor":"E.Gavrilov","issue_id":"goon-game-bots-1-4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Уровни 1–4 проходят production-input ботами по 3 раза подряд; финальный integrated Android 16 matrix 14/14 green."}}
{"id":"int-ab10aee3","kind":"field_change","created_at":"2026-08-18T01:30:37.522248Z","actor":"E.Gavrilov","issue_id":"goon-game-level6-freezer-overlap","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"FREEZER больше не убивает игрока на frozen stepping stone; floor lethality сохранена; уровень 6 проходит 3/3."}}
{"id":"int-129af3b6","kind":"field_change","created_at":"2026-08-18T01:30:42.004043Z","actor":"E.Gavrilov","issue_id":"goon-game-level8-softlock","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Expiry справа от PIT воспроизводился; recovery relay и баланс таймера проверены intended 3/3 и recovery 3/3."}}
{"id":"int-2dabc667","kind":"field_change","created_at":"2026-08-18T01:30:46.06691Z","actor":"E.Gavrilov","issue_id":"goon-game-bots-6-10","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Уровни 6–10 проходят intended routes по 3 раза; уровни 8 и 10 дополнительно проходят 96ms stress checks."}}
+7 -6
View File
@@ -1,16 +1,17 @@
{"_type":"issue","id":"goon-game-level8-softlock","title":"Устранить softlock таймера уровня 8","description":"Если clock истекает после пересечения PIT, дверь закрывается, платформа останавливается, а переключатель остаётся слева: игрок может оказаться заперт справа до restart.","acceptance_criteria":"Бот воспроизводит expiry после PIT; игрок всегда имеет путь к restart-free recovery или таймер/геометрия исключают ловушку; сценарий проходит 3 раза подряд.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"open","priority":0,"issue_type":"bug","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:40Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T22:09:40Z","labels":["bug","current","level8","softlock"],"dependencies":[{"issue_id":"goon-game-level8-softlock","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:09:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-level6-freezer-overlap","title":"Исправить повторную смерть на frozen corpse уровня 6","description":"Frozen corpse уровня 6 фиксируется на y=776..880, а FREEZER остаётся y=750..880; игрок на теле занимает y=676..776 и пересекает hazard на 26 px, поэтому intended stepping-stone route вызывает немедленную вторую смерть.","acceptance_criteria":"Игрок может стоять и прыгнуть с предусмотренного frozen corpse без повторной FREEZE-смерти; вход в FREEZER с пола всё ещё убивает; intended route уровня 6 проходит три раза подряд production-input ботом.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:46:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T01:30:37Z","started_at":"2026-08-17T22:46:52Z","closed_at":"2026-08-18T01:30:37Z","close_reason":"FREEZER больше не убивает игрока на frozen stepping stone; floor lethality сохранена; уровень 6 проходит 3/3.","labels":["bug","current","freeze","level6"],"dependencies":[{"issue_id":"goon-game-level6-freezer-overlap","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:46:51Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-level8-softlock","title":"Устранить softlock таймера уровня 8","description":"Если clock истекает после пересечения PIT, дверь закрывается, платформа останавливается, а переключатель остаётся слева: игрок может оказаться заперт справа до restart.","acceptance_criteria":"Бот воспроизводит expiry после PIT; игрок всегда имеет путь к restart-free recovery или таймер/геометрия исключают ловушку; сценарий проходит 3 раза подряд.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":0,"issue_type":"bug","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:40Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T01:30:42Z","started_at":"2026-08-17T22:33:08Z","closed_at":"2026-08-18T01:30:42Z","close_reason":"Expiry справа от PIT воспроизводился; recovery relay и баланс таймера проверены intended 3/3 и recovery 3/3.","labels":["bug","current","level8","softlock"],"dependencies":[{"issue_id":"goon-game-level8-softlock","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:09:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-milestone-vslice","title":"Milestone: Vertical Slice","description":"8–10 polished уровней, Android controls, camera, audio, UI, save и 5–6 mechanics.","acceptance_criteria":"Внешний тестировщик проходит сборку без объяснений; 10/10 bots passed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:44:10Z","started_at":"2026-08-17T21:41:29Z","labels":["current","gate","milestone"],"dependencies":[{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-android-lifecycle","type":"blocks","created_at":"2026-08-18T00:44:22Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-art-vslice","type":"blocks","created_at":"2026-08-18T00:44:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-audio-vslice","type":"blocks","created_at":"2026-08-18T00:44:19Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-controls-settings","type":"blocks","created_at":"2026-08-18T00:44:17Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:55Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-human-playtest","type":"blocks","created_at":"2026-08-18T00:44:16Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-menus-localization","type":"blocks","created_at":"2026-08-18T00:44:18Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:44:23Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:44:15Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-vslice","depends_on_id":"goon-game-save-versioning","type":"blocks","created_at":"2026-08-18T00:44:21Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":9,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-milestone-prototype","title":"Milestone: Prototype","description":"Character, death, corpse, respawn, spikes, plate, door, goal и restart без финального art.","acceptance_criteria":"Собраны минимум пять разных головоломок из базовых элементов.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:44:01Z","closed_at":"2026-08-17T21:41:27Z","close_reason":"Exit criteria прототипа подтверждены 10 greybox-уровнями.","labels":["closed","milestone"],"dependencies":[{"issue_id":"goon-game-milestone-prototype","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-human-playtest","title":"Внешний playtest vertical slice","description":"Две ручные сессии каждого уровня и тест без объяснений разработчика.","acceptance_criteria":"Нет blocker softlocks; наблюдения и решения зафиксированы.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:54Z","labels":["qa","vertical-slice"],"dependencies":[{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:51Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-human-playtest","depends_on_id":"goon-game-level-balance","type":"blocks","created_at":"2026-08-18T00:41:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-regression-10","title":"Единый playability regression 10/10","description":"Запускать всех ботов одной Gradle-командой и публиковать краткий отчёт по уровням.","acceptance_criteria":"Три полных последовательных прогона дают 10/10 passed менее чем за 5 минут каждый.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":480,"created_at":"2026-08-17T21:40:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:51Z","labels":["current","gate","qa"],"dependencies":[{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-1-4","type":"blocks","created_at":"2026-08-18T00:41:33Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-6-10","type":"blocks","created_at":"2026-08-18T00:41:34Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-bots-6-10","title":"Добавить ботов уровней 6–10","description":"Доказать freeze, electricity, timed platform, multi-signal и mastery level.","acceptance_criteria":"Уровни 6–10 проходят по три раза подряд без retries.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:49Z","labels":["bot","current","qa"],"dependencies":[{"issue_id":"goon-game-bots-6-10","depends_on_id":"goon-game-bot-framework","type":"blocks","created_at":"2026-08-18T00:41:32Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-bots-6-10","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:49Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-bots-1-4","title":"Добавить ботов уровней 1–4","description":"Доказать movement/jump, spikes/corpses, pressure door и moving lift реальными inputs.","acceptance_criteria":"Уровни 1–4 проходят по три раза подряд без retries.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:47Z","labels":["bot","current","qa"],"dependencies":[{"issue_id":"goon-game-bots-1-4","depends_on_id":"goon-game-bot-framework","type":"blocks","created_at":"2026-08-18T00:41:30Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-bots-1-4","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-regression-10","title":"Единый playability regression 10/10","description":"Запускать всех ботов одной Gradle-командой и публиковать краткий отчёт по уровням.","acceptance_criteria":"Три полных последовательных прогона дают 10/10 passed менее чем за 5 минут каждый.","notes":"2026-08-18: one final integrated Android 16 run is green: 14/14 methods, 35 gameplay executions plus lifecycle, Gradle wall time 5:59. Correctness is proven, but acceptance remains open because the \u003c5:00 target is missed by 59s and three full matrix repetitions are not yet recorded.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":480,"created_at":"2026-08-17T21:40:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T01:31:00Z","labels":["current","gate","qa"],"dependencies":[{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-1-4","type":"blocks","created_at":"2026-08-18T00:41:33Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-bots-6-10","type":"blocks","created_at":"2026-08-18T00:41:34Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-regression-10","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-bots-6-10","title":"Добавить ботов уровней 6–10","description":"Доказать freeze, electricity, timed platform, multi-signal и mastery level.","acceptance_criteria":"Уровни 6–10 проходят по три раза подряд без retries.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T01:30:46Z","started_at":"2026-08-17T22:33:08Z","closed_at":"2026-08-18T01:30:46Z","close_reason":"Уровни 6–10 проходят intended routes по 3 раза; уровни 8 и 10 дополнительно проходят 96ms stress checks.","labels":["bot","current","qa"],"dependencies":[{"issue_id":"goon-game-bots-6-10","depends_on_id":"goon-game-bot-framework","type":"blocks","created_at":"2026-08-18T00:41:32Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-bots-6-10","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:49Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-bots-6-10","depends_on_id":"goon-game-level6-freezer-overlap","type":"blocks","created_at":"2026-08-18T01:47:00Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-bots-1-4","title":"Добавить ботов уровней 1–4","description":"Доказать movement/jump, spikes/corpses, pressure door и moving lift реальными inputs.","acceptance_criteria":"Уровни 1–4 проходят по три раза подряд без retries.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T01:30:34Z","started_at":"2026-08-17T22:33:08Z","closed_at":"2026-08-18T01:30:34Z","close_reason":"Уровни 1–4 проходят production-input ботами по 3 раза подряд; финальный integrated Android 16 matrix 14/14 green.","labels":["bot","current","qa"],"dependencies":[{"issue_id":"goon-game-bots-1-4","depends_on_id":"goon-game-bot-framework","type":"blocks","created_at":"2026-08-18T00:41:30Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-bots-1-4","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-bot-framework","title":"Обобщить acceptance bot framework","description":"Выделить сценарные шаги, snapshots, диагностику таймаутов и общий runner для уровней 1–10.","acceptance_criteria":"Новый уровень подключается декларативным сценарием; ошибка показывает последний шаг и state.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T22:09:01Z","started_at":"2026-08-17T21:52:45Z","closed_at":"2026-08-17T22:09:01Z","close_reason":"Декларативный Scenario runner, именованные шаги, per-step timeout и observable diagnostics реализованы; 11 JVM-тестов, lint, instrumentation compile и connected level 5 GREEN на Android 16.","labels":["bot","current","qa"],"dependencies":[{"issue_id":"goon-game-bot-framework","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-level5-bot","title":"Acceptance-бот уровня 5","description":"Бот использует production GameView inputs, одну жертву, тело, плиту, дверь и goal.","acceptance_criteria":"Инструментальный тест проходит на Android 16 примерно за 8,2 секунды.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:42Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:25Z","closed_at":"2026-08-17T21:41:25Z","close_reason":"Android acceptance-тест уровня 5 проходит.","labels":["baseline","bot","closed"],"dependencies":[{"issue_id":"goon-game-level5-bot","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:46Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-unit-tests","title":"Базовые unit tests и Android Lint","description":"В текущем проекте проходят 8 unit-тестов и lintDebug.","acceptance_criteria":"Чистая Gradle-сборка зелёная.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:24Z","closed_at":"2026-08-17T21:41:24Z","close_reason":"8 unit-тестов и lintDebug прошли.","labels":["baseline","closed","tests"],"dependencies":[{"issue_id":"goon-game-unit-tests","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-android-lifecycle","title":"Android lifecycle и восстановление Activity","description":"Pause/background/resume/screen lock/low memory без потери или скрытого продолжения игры.","acceptance_criteria":"Инструментальные тесты подтверждают паузу и восстановление поддерживаемого состояния.","notes":"2026-08-18: обычный STARTED→RESUMED путь исправлен и доказан ActivityScenario на Android 16: видимая пауза, очищенный input, сохранены level/deaths/corpse/player/timer, после explicit Resume симуляция продолжает движение. Connected GREEN 1/1. Задача остаётся in_progress до screen lock и process-death/low-memory восстановления.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:26Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T22:09:20Z","started_at":"2026-08-17T21:52:46Z","labels":["android","current"],"dependencies":[{"issue_id":"goon-game-android-lifecycle","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:36Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"goon-game-android-apk","title":"Собираемый и устанавливаемый Android APK","description":"Debug APK versionCode 3 проверен на Android 16, ZIP и подписи валидны.","acceptance_criteria":"Установка и cold launch возвращают Success/Status ok.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:24Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:23Z","closed_at":"2026-08-17T21:41:23Z","close_reason":"APK versionCode 3 собран, подписан, установлен и запущен.","labels":["baseline","closed"],"dependencies":[{"issue_id":"goon-game-android-apk","depends_on_id":"goon-game-e12","type":"parent-child","created_at":"2026-08-18T00:43:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-level-balance","title":"Исправить стабильность и баланс уровней 1–10","description":"Устранить softlocks, нестабильные тела и чрезмерно точные прыжки, найденные ботами и ручным тестированием.","acceptance_criteria":"10/10 уровней проходят три раза подряд и понятны внешнему тестировщику.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:56Z","labels":["balance","current","levels"],"dependencies":[{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level-route-risks","type":"blocks","created_at":"2026-08-18T01:10:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level8-softlock","type":"blocks","created_at":"2026-08-18T01:10:14Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-solution-graphs","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-level-balance","title":"Исправить стабильность и баланс уровней 1–10","description":"Устранить softlocks, нестабильные тела и чрезмерно точные прыжки, найденные ботами и ручным тестированием.","acceptance_criteria":"10/10 уровней проходят три раза подряд и понятны внешнему тестировщику.","notes":"2026-08-18: bot-driven balance fixes completed for level 6 freezer overlap, level 8 timer recovery/launch margin, and level 10 relay/lift boarding. Intended routes 1–10 pass 3x in one integrated matrix. Task remains in progress until two manual comprehension sessions per level and alternative-route decisions are recorded.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T01:31:03Z","started_at":"2026-08-18T01:31:03Z","labels":["balance","current","levels"],"dependencies":[{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:20Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level-route-risks","type":"blocks","created_at":"2026-08-18T01:10:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-level8-softlock","type":"blocks","created_at":"2026-08-18T01:10:14Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-regression-10","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-level-balance","depends_on_id":"goon-game-solution-graphs","type":"blocks","created_at":"2026-08-18T00:41:35Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-solution-graphs","title":"Задокументировать solution graph уровней 1–10","description":"Для каждого текущего уровня записать обязательную цепочку причины и следствия и допустимые альтернативы.","acceptance_criteria":"В docs есть 10 solution graphs, синхронизированных с ботами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T22:09:09Z","started_at":"2026-08-17T21:52:46Z","closed_at":"2026-08-17T22:09:09Z","close_reason":"Документированы 10 source-backed solution graphs с механиками, смертями, checkpoints и рисками; только уровень 5 честно отмечен доказанным connected-тестом.","labels":["current","design","levels"],"dependencies":[{"issue_id":"goon-game-solution-graphs","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:19Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-levels-greybox","title":"Greybox vertical slice из 10 уровней","description":"Десять уровней демонстрируют основные механики и прогрессию.","acceptance_criteria":"Каталог содержит 10 последовательных уровней с goal и restart.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":0,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:52Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:19Z","closed_at":"2026-08-17T21:41:19Z","close_reason":"Созданы 10 последовательных greybox-уровней.","labels":["baseline","closed","vertical-slice"],"dependencies":[{"issue_id":"goon-game-levels-greybox","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:17Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-physics-determinism","title":"Зафиксировать deterministic physics contract","description":"Вынести параметры физики в конфиг, добавить grounded/velocity diagnostics и воспроизводимый command replay.","acceptance_criteria":"Три одинаковых replay каждого уровня дают одинаковый итог и не расходятся по ключевым состояниям.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:33Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:33Z","labels":["current","physics"],"dependencies":[{"issue_id":"goon-game-physics-determinism","depends_on_id":"goon-game-e03","type":"parent-child","created_at":"2026-08-18T00:43:08Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
@@ -23,7 +24,7 @@
{"_type":"issue","id":"goon-game-e03","title":"EPIC-03 Physics","description":"Предсказуемая гибридная физика и бюджеты активных объектов. Разделы 15, 41–43, 65.","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:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:38:49Z","labels":["epic","physics"],"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-17T21:38:47Z","labels":["corpse","death","epic"],"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-17T21:44:08Z","started_at":"2026-08-17T21:41:28Z","labels":["gdd","master","production","tdd"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-level-route-risks","title":"Доказать спорные маршруты уровней 2, 4 и 10","description":"Проверить возможный sequence break уровня 2, узкий lift→gallery прыжок уровня 4 и optional FREEZER/body delivery/lift→ledge риски уровня 10.","acceptance_criteria":"Для каждого риска есть воспроизводимый connected bot test; intended route проходит 3 раза, а допустимые альтернативы явно зафиксированы или геометрия исправлена.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T22:09:58Z","labels":["balance","bot","current","levels"],"dependencies":[{"issue_id":"goon-game-level-route-risks","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:10:06Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-level-route-risks","title":"Доказать спорные маршруты уровней 2, 4 и 10","description":"Проверить возможный sequence break уровня 2, узкий lift→gallery прыжок уровня 4 и optional FREEZER/body delivery/lift→ledge риски уровня 10.","acceptance_criteria":"Для каждого риска есть воспроизводимый connected bot test; intended route проходит 3 раза, а допустимые альтернативы явно зафиксированы или геометрия исправлена.","notes":"2026-08-18: exploratory production-input direct-jump attempt on level 2 did not confirm the suspected zero-death sequence break; it timed out at x=549.36 with deaths=2/corpses=2. Intended three-corpse route separately passes 3 times. Sequence-break risk remains unproven and open.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T22:53:56Z","labels":["balance","bot","current","levels"],"dependencies":[{"issue_id":"goon-game-level-route-risks","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:10:06Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-mobile-qa","title":"Mobile QA matrix","description":"GPU, API, RAM, refresh rate, thermal, background, battery и controller проверки.","acceptance_criteria":"Матрица из раздела 49 заполнена и приложена к release evidence.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:56Z","labels":["android","qa"],"dependencies":[{"issue_id":"goon-game-mobile-qa","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:41:39Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-mobile-qa","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:53Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-crash-reporting","title":"Crash/ANR reporting без персональных данных","description":"Подключить отчёты ошибок с build/level context и политикой приватности.","acceptance_criteria":"Тестовая ошибка видна в системе; payload не содержит пользовательских секретов.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:38Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:38Z","labels":["analytics","android"],"dependencies":[{"issue_id":"goon-game-crash-reporting","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-corpse-sleep-pooling","title":"Sleeping corpses и object pooling","description":"Усыплять остановившиеся тела и pool-ить FX/audio/projectiles без изменения головоломок.","acceptance_criteria":"Нет GC spikes; спящие тела корректно пробуждаются внешним воздействием.","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:40:34Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:34Z","labels":["corpse","performance"],"dependencies":[{"issue_id":"goon-game-corpse-sleep-pooling","depends_on_id":"goon-game-corpse-manager","type":"blocks","created_at":"2026-08-18T00:41:39Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-sleep-pooling","depends_on_id":"goon-game-e13","type":"parent-child","created_at":"2026-08-18T00:43:41Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-sleep-pooling","depends_on_id":"goon-game-perf-budgets","type":"blocks","created_at":"2026-08-18T00:41:40Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
+50 -25
View File
@@ -1,36 +1,61 @@
# QA report
# QA report — Stage 2 playability build
Date: 2026-08-18
## Result
The version-4 debug APK passed the final clean build and one integrated Android 16
device run. All ten intended level routes completed three consecutive times through
the production movement/jump input boundary. No retry masking, teleport, direct
physics mutation, auto-complete, or restart shortcut is used.
## Automated checks
- Clean Gradle build: `clean testDebugUnitTest lintDebug assembleDebug` — passed.
- Unit tests: 8 passed, 0 failed, 0 skipped.
- Device acceptance tests: 1 passed, 0 failed, 0 skipped.
- Level 5 bot: completed the production level in about 8.2 seconds with exactly one death, one retained corpse, pressure-plate activation, door passage, two jumps, and goal contact.
- Android Lint: no issues found.
- APK archive integrity: all entries passed `unzip -t`.
- Package metadata: `com.projectsacrifice.game.debug`, version code 3, min SDK 23, target SDK 34.
- APK signature: v1 and v2 schemes verified; one standard Android Debug signer.
- Clean Gradle gate: `clean testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest` — passed, 66 tasks.
- JVM tests: 15 passed, 0 failed, 0 skipped.
- Integrated device gate: `connectedDebugAndroidTest` — 14 passed, 0 failed, 0 skipped.
- Device test time: 349.702 seconds; Gradle wall time: 5 minutes 59 seconds.
- Gameplay executions: 30 intended routes (levels 1–10 × 3), three level-8 recovery routes, and two delayed-decision stress routes; lifecycle coverage is a separate method.
- Android Lint: passed without errors.
## Device smoke test
The five-minute matrix target is not met: the green run exceeds it by 59 seconds.
Correctness coverage remains intact; test-duration optimization stays open.
Environment: official ARM64 Android Emulator, Android 16 / API 36, 2400×1080 landscape.
## Playability evidence
Verified:
- Level 1: movement/jump route, 3/3, zero deaths.
- Level 2: intended three-corpse bridge, 3/3, exactly three deaths and bodies.
- Level 3: saw corpse holds the pressure door, 3/3, exactly one death/body.
- Level 4: toggle, moving lift, and upper gallery, 3/3, zero deaths.
- Level 5: saw delivery to remote pressure plate, 3/3, exactly one death/body.
- Level 6: frozen corpse stepping stone, 3/3, exactly one death/body.
- Level 7: conductive corpse relay, 3/3, exactly one death/body.
- Level 8: intended timed-platform route 3/3, expired-timer recovery 3/3, and one 96 ms delayed-decision run; all deathless.
- Level 9: weight AND power door, 3/3, exactly two deaths/bodies.
- Level 10: saw/freeze/electric/lift procession, 3/3, exactly three deaths/bodies, plus one 96 ms delayed final-traversal run.
- Activity background/resume: visible pause, held-input clearing, state preservation, and explicit resume passed.
1. streamed APK installation returns `Success`;
2. cold launch returns `Status: ok`;
3. `MainActivity` remains visible and `RESUMED`;
4. immersive landscape mode is applied;
5. level-selection screen renders correctly;
6. entering level 1 renders the HUD, character, goal, and mobile controls;
7. holding the right control completes level 1;
8. completion records best time/deaths and unlocks level 2 (`unlocked=2`);
9. reaching spikes in level 2 increments the volunteer counter, keeps the corpse in the level, and respawns a live volunteer without resetting the scene;
10. Android crash buffer remains empty after the corrected build and gameplay smoke tests.
11. the level 5 acceptance bot reproduces the complete saw/body/plate/door solution on the same Android 16 emulator; its initial red runs exposed an unstable saw impulse, and the final run passed after the body delivery was corrected.
## Defects found and fixed by the integrated gate
## Distribution note
1. Level 6 freezer overlapped the player standing on its frozen body by 26 px. The hazard height was narrowed while retaining floor lethality.
2. Level 8 trapped the player beyond the pit after the timer expired. A high far-side recovery relay was added; the original timer received a 0.5-second balance margin.
3. Level 8 platform boarding failed under delayed decisions. The left launch floor was extended by 70 px while the pit's far edge, platform, timers, and recovery relay stayed unchanged.
4. Level 10 relay clearing and lift boarding were phase-sensitive. The bot now proves actual moving-platform support; floor/lift overlap increased from 40 to 100 px while retaining a 200 px pit and mandatory 410 px lift.
The delivered APK is a debug-signed tester build. It is directly installable, but it is not intended for Play Store publication. A production release should use the publisher's private release keystore and a non-debug application ID.
## APK verification
- File format: valid ZIP/APK; `unzip -t` reports no errors.
- Package: `com.projectsacrifice.game.debug`.
- Version: code `4`, name `0.3.0-stage2-playability-debug`.
- SDK: min 23, target 34, compile 34.
- Signature: v1 and v2 verified; one standard Android Debug signer.
- SHA-256: `94652cfe7e7eacbb41d7890944f9adf85b989fde89764809e3b8bfab8e64a12d`.
- Installation/runtime: the exact APK was installed and exercised by the final Android 16 connected test task.
## Remaining scope
- Stage 2 manual comprehension/balance sessions have not yet been completed.
- Possible alternative solutions on levels 2 and 10 still need dedicated negative tests.
- Screen-lock and process-death/low-memory lifecycle recovery remain open.
- The character is still Canvas-drawn prototype art; there is no skeleton/rig or skin pipeline yet.
- This is a debug-signed tester APK, not a Play Store release build.
+6 -6
View File
@@ -43,14 +43,14 @@ Requirements: JDK 17 and Android SDK Platform/Build Tools 34.
./gradlew testDebugUnitTest assembleDebug
```
Level 5 has a device-level acceptance bot. It drives the production `GameView`
through the same movement and jump inputs as the touch controls, sacrifices one
volunteer to the saw, verifies the retained body and pressure-plate door, then
reaches the goal. With an emulator or phone connected, run it with:
All ten 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. With an emulator or
phone connected, run the complete matrix with:
```sh
./gradlew connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.projectsacrifice.game.LevelFivePlaythroughTest
./gradlew connectedDebugAndroidTest
```
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:
+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId 'com.projectsacrifice.game'
minSdk 23
targetSdk 34
versionCode 3
versionName '0.2.1-level5-proof'
versionCode 4
versionName '0.3.0-stage2-playability'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
@@ -0,0 +1,119 @@
package com.projectsacrifice.game;
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;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/** Production-input acceptance coverage for the intended routes through levels 1-4. */
@RunWith(AndroidJUnit4.class)
public final class EarlyLevelPlaythroughTest {
@Test
public void levelOneProvesMovementAndJumpWithoutDeaths() {
runThreeConsecutive(EarlyLevelScenarios.levelOne(), 20_000L, (pass, result) -> {
assertTrue(passDiagnostic(pass, result), result.completed());
assertEquals("Pass " + pass + ": level 1 must not require a volunteer",
0, result.deaths());
});
}
@Test
public void levelTwoBuildsTheIntendedThreeCorpseBridge() {
runThreeConsecutive(EarlyLevelScenarios.levelTwo(), 30_000L, (pass, result) -> {
assertTrue(passDiagnostic(pass, result), result.completed());
assertEquals("Pass " + pass + ": the intended bridge uses three volunteers",
3, result.deaths());
assertEquals("Pass " + pass + ": all three bridge bodies must remain",
3, result.corpseCount());
});
}
@Test
public void levelThreeLeavesASawCorpseOnThePlateAndOpensTheDoor() {
runThreeConsecutive(EarlyLevelScenarios.levelThree(), 25_000L, (pass, result) -> {
assertTrue(passDiagnostic(pass, result), result.completed());
assertEquals("Pass " + pass + ": the saw solution requires one volunteer",
1, result.deaths());
assertEquals("Pass " + pass + ": the plate body must remain",
1, result.corpseCount());
assertTrue("Pass " + pass + ": the corpse must keep the pressure door open",
result.doorOpened());
});
}
@Test
public void levelFourTogglesAndRidesTheLiftToTheUpperGallery() {
runThreeConsecutive(EarlyLevelScenarios.levelFour(), 30_000L, (pass, result) -> {
assertTrue(passDiagnostic(pass, result), result.completed());
assertEquals("Pass " + pass + ": the lift route must not require a volunteer",
0, result.deaths());
});
}
private static String passDiagnostic(int pass, LevelPlaytestBot.Result result) {
return "Consecutive pass " + pass + ": " + result.diagnostic();
}
private static void runThreeConsecutive(LevelPlaytestBot.Scenario scenario, long timeoutMillis,
ResultAssertion assertion) {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
final GameView[] view = new GameView[1];
activity.onActivity(mainActivity -> view[0] = mainActivity.gameViewForAutomation());
AndroidDriver driver = new AndroidDriver(
InstrumentationRegistry.getInstrumentation(), view[0]);
LevelPlaytestBot bot = new LevelPlaytestBot();
for (int pass = 1; pass <= 3; pass++) {
assertion.verify(pass, bot.run(driver, scenario, timeoutMillis));
}
}
}
@FunctionalInterface
private interface ResultAssertion {
void verify(int pass, LevelPlaytestBot.Result result);
}
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView gameView;
AndroidDriver(Instrumentation instrumentation, GameView gameView) {
this.instrumentation = instrumentation;
this.gameView = gameView;
}
@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() {
final GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> value[0] = gameView.automationSnapshot());
return value[0];
}
@Override
public void waitForNextDecision(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Early-level bot interrupted", interrupted);
}
}
}
}
@@ -0,0 +1,199 @@
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;
/** Production-input acceptance coverage for levels 6, 7, 9 and 10. */
@RunWith(AndroidJUnit4.class)
public final class LateLevelPlaythroughTest {
private static final int CONSECUTIVE_RUNS = 3;
@Test
public void frozenVolunteerIsUsedToCompleteLevelSixThreeTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameDriver driver = GameDriver.from(activity);
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
driver,
LateLevelScenarios.levelSix(),
30_000L
);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run + "; intended route uses one frozen volunteer",
1, result.deaths());
assertEquals("run=" + run + "; frozen block must remain in the puzzle",
1, result.corpseCount());
}
}
}
@Test
public void conductiveVolunteerKeepsLevelSevenDoorOpenThreeTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameDriver driver = GameDriver.from(activity);
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
driver,
LateLevelScenarios.levelSeven(),
25_000L
);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run + "; intended route uses one conductive volunteer",
1, result.deaths());
assertEquals("run=" + run + "; conductive body must remain in the relay",
1, result.corpseCount());
assertTrue("run=" + run + "; conductive relay must open the door",
result.doorOpened());
}
}
}
@Test
public void weightAndPowerOpenLevelNineDoorThreeTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameDriver driver = GameDriver.from(activity);
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
driver,
LateLevelScenarios.levelNine(),
35_000L
);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run + "; weight and power require two volunteers",
2, result.deaths());
assertEquals("run=" + run + "; both signal bodies must remain",
2, result.corpseCount());
assertTrue("run=" + run + "; both signals must open the AND door",
result.doorOpened());
}
}
}
@Test
public void masteryProcessionCompletesLevelTenThreeTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameDriver driver = GameDriver.from(activity);
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
driver,
LateLevelScenarios.levelTen(),
55_000L
);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run + "; mastery route uses three volunteers",
3, result.deaths());
assertEquals("run=" + run + "; launch, freeze and relay bodies must remain",
3, result.corpseCount());
assertTrue("run=" + run + "; conductive relay must open the final door",
result.doorOpened());
}
}
}
@Test
public void masteryProcessionSurvivesThrottledFinalTraversalDecisions() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameDriver driver = GameDriver.withFinalTraversalDelay(activity, 96L);
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
driver,
LateLevelScenarios.levelTen(),
65_000L
);
assertTrue(result.diagnostic(), result.completed());
assertEquals("throttling must not add accidental volunteers", 3, result.deaths());
assertEquals("all three intended bodies must remain", 3, result.corpseCount());
assertTrue("the conductive relay must still open the final door",
result.doorOpened());
}
}
private static final class GameDriver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView gameView;
private final long minimumDecisionMillis;
private final boolean throttleFinalTraversalOnly;
private GameView.AutomationSnapshot lastSnapshot;
private GameDriver(Instrumentation instrumentation, GameView gameView,
long minimumDecisionMillis, boolean throttleFinalTraversalOnly) {
this.instrumentation = instrumentation;
this.gameView = gameView;
this.minimumDecisionMillis = minimumDecisionMillis;
this.throttleFinalTraversalOnly = throttleFinalTraversalOnly;
}
static GameDriver from(ActivityScenario<MainActivity> activity) {
return create(activity, 32L, false);
}
static GameDriver withFinalTraversalDelay(ActivityScenario<MainActivity> activity,
long minimumDecisionMillis) {
return create(activity, minimumDecisionMillis, true);
}
private static GameDriver create(ActivityScenario<MainActivity> activity,
long minimumDecisionMillis,
boolean throttleFinalTraversalOnly) {
GameView[] view = new GameView[1];
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
return new GameDriver(InstrumentationRegistry.getInstrumentation(), view[0],
minimumDecisionMillis, throttleFinalTraversalOnly);
}
@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[] value = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> value[0] = gameView.automationSnapshot());
lastSnapshot = value[0];
return lastSnapshot;
}
@Override
public void waitForNextDecision(long millis) {
try {
boolean inFinalTraversalWindow = throttleFinalTraversalOnly
&& lastSnapshot != null
&& lastSnapshot.levelNumber() == 10
&& lastSnapshot.deaths() == 3
&& lastSnapshot.playerX() >= 1_200f
&& lastSnapshot.playerX() <= 2_200f;
Thread.sleep(inFinalTraversalWindow
? Math.max(millis, minimumDecisionMillis) : millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Late-level bot interrupted", interrupted);
}
}
}
}
@@ -0,0 +1,239 @@
package com.projectsacrifice.game;
/** Declarative intended-route scenarios for the late vertical-slice levels. */
final class LateLevelScenarios {
private LateLevelScenarios() {}
static LevelPlaytestBot.Scenario levelSix() {
return LevelPlaytestBot.Scenario.builder(6, "Level 6 - frozen corpse step")
.step("Freeze one volunteer", 4_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the frozen block", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 390f)
.step("Jump toward the frozen block", 1_500L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 640f)
.step("Stand on the frozen block", 2_500L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerX() >= 626f
&& snapshot.playerY() >= 668f
&& snapshot.playerY() <= 690f)
.step("Jump from the block to the first ledge", 2_500L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 750f
&& snapshot.playerY() <= 505f)
.step("Cross the first ledge", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 1_230f)
.step("Jump the gap to the second ledge", 2_500L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 1_400f
&& snapshot.playerY() <= 520f)
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
static LevelPlaytestBot.Scenario levelSeven() {
return LevelPlaytestBot.Scenario.builder(7, "Level 7 - conductive relay")
.step("Power the relay with one volunteer", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1)
.step("Observe the powered door", 1_500L, LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::allDoorsOpen)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the live relay", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 460f)
.step("Take off before the conductive relay", 1_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f)
.step("Clear the conductive relay", 2_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 780f)
.step("Pass the powered door", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 1_230f)
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
static LevelPlaytestBot.Scenario levelNine() {
return LevelPlaytestBot.Scenario.builder(9, "Level 9 - weight AND power")
.step("Leave one volunteer on the weight plate", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1
&& !snapshot.allDoorsOpen())
.step("Wait for the second volunteer", 3_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the weighted spikes", 2_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 390f)
.step("Jump over the weighted spikes", 2_500L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
.step("Approach the unpowered relay", 2_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 820f)
.step("Power the relay with the second volunteer", 2_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 2
&& snapshot.corpseCount() == 2)
.step("Observe weight AND power opening the door", 1_500L,
LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::allDoorsOpen)
.step("Wait for the final volunteer", 3_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Return to the weighted spikes", 2_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 390f)
.step("Clear the weighted spikes", 2_500L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
.step("Approach the powered relay", 2_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 720f)
.step("Take off before the powered relay", 1_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f)
.step("Clear the powered relay", 2_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_040f)
.step("Pass the AND door", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 1_610f)
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
static LevelPlaytestBot.Scenario levelTen() {
return LevelPlaytestBot.Scenario.builder(10, "Level 10 - mastery procession")
.step("Launch one volunteer toward the lift plate", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1)
.step("Wait for the freezer volunteer", 3_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the launch saw", 2_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 350f)
.step("Take off before the launch saw", 1_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f)
.step("Clear the launch saw", 2_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
.step("Approach the delivered lift body", 1_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 730f)
.step("Jump over the delivered lift body", 2_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 950f)
.step("Enter the freezer", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 2
&& snapshot.corpseCount() == 2)
.step("Wait for the relay volunteer", 3_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Return to the launch saw", 2_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 350f)
.step("Take off before the saw again", 1_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f)
.step("Clear the saw again", 2_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
.step("Approach the lift body again", 1_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 730f)
.step("Clear the lift body again", 2_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 860f)
.step("Prepare the frozen-block jump", 2_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerY() >= 720f)
.step("Jump over the frozen block", 2_500L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_240f)
.step("Power the final relay", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 3
&& snapshot.corpseCount() == 3)
.step("Observe the final door open", 1_500L,
LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::allDoorsOpen)
.step("Wait for the final volunteer", 3_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the saw for the final crossing", 2_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 350f)
.step("Take off for the final saw crossing", 1_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f)
.step("Clear the saw for the final crossing", 2_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
.step("Approach the retained lift body", 1_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 730f)
.step("Clear the retained lift body", 2_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 860f)
.step("Prepare the final frozen-block jump", 2_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerY() >= 720f)
.step("Clear the frozen block", 2_500L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_240f)
.step("Approach the powered relay", 2_500L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 1_320f)
.step("Take off before the final relay", 1_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f)
.step("Clear the powered relay", 2_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_650f)
.step("Wait for the lift at floor height", 6_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive()
&& snapshot.playerY() >= 770f
&& !Float.isNaN(snapshot.firstMovingPlatformY())
&& snapshot.firstMovingPlatformY() >= 780f)
.step("Take off toward the moving lift", 1_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 670f)
.step("Board the moving lift", 2_000L,
LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::playerOnMovingPlatform)
.step("Brake toward the lift center", 1_000L,
LevelPlaytestBot.Input.left(),
snapshot -> snapshot.playerOnMovingPlatform()
&& snapshot.playerX() <= 1_900f)
.step("Center on the moving lift", 1_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerOnMovingPlatform()
&& snapshot.playerX() >= 1_920f)
.step("Ride the lift to the upper door", 5_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerY() <= 310f)
.step("Move to the raised lift edge", 2_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 2_000f
&& snapshot.playerY() <= 340f)
.step("Jump through the door to the upper gallery", 3_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 2_400f
&& snapshot.playerY() <= 360f)
.step("Reach the final relic", 4_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
}
@@ -0,0 +1,110 @@
package com.projectsacrifice.game;
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;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/** Device acceptance tests for the level-8 timed-platform puzzle and recovery route. */
@RunWith(AndroidJUnit4.class)
public final class LevelEightPlaythroughTest {
private static final int REQUIRED_CONSECUTIVE_RUNS = 3;
@Test
public void expiredClockCanBeRearmedFromTheFarSideWithoutRestart() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
AndroidDriver driver = driver(activity);
for (int run = 1; run <= REQUIRED_CONSECUTIVE_RUNS; run++) {
LevelEightScenario.Result result = new LevelEightScenario()
.recoverAfterClockExpires(driver, 20_000L);
assertTrue("Recovery run " + run + ": " + result.diagnostic(), result.completed());
assertEquals("Recovery must not hide a reset/death shortcut", 0, result.deaths());
}
}
}
@Test
public void intendedTimedPlatformRouteCompletesThreeConsecutiveTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
AndroidDriver driver = driver(activity);
for (int run = 1; run <= REQUIRED_CONSECUTIVE_RUNS; run++) {
LevelEightScenario.Result result = new LevelEightScenario()
.completeIntendedRoute(driver, 15_000L);
assertTrue("Intended run " + run + ": " + result.diagnostic(), result.completed());
assertEquals("The intended route is deathless", 0, result.deaths());
}
}
}
@Test
public void intendedRouteSurvivesStressedDecisionScheduling() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
LevelEightScenario.Result result = new LevelEightScenario()
.completeIntendedRoute(driver(activity, 96L), 20_000L);
assertTrue("Stressed scheduling: " + result.diagnostic(), result.completed());
assertEquals("The stressed intended route is deathless", 0, result.deaths());
}
}
private static AndroidDriver driver(ActivityScenario<MainActivity> activity) {
return driver(activity, 0L);
}
private static AndroidDriver driver(ActivityScenario<MainActivity> activity,
long extraDecisionDelayMillis) {
final GameView[] view = new GameView[1];
activity.onActivity(mainActivity -> view[0] = mainActivity.gameViewForAutomation());
return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0],
extraDecisionDelayMillis);
}
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView gameView;
private final long extraDecisionDelayMillis;
AndroidDriver(Instrumentation instrumentation, GameView gameView,
long extraDecisionDelayMillis) {
this.instrumentation = instrumentation;
this.gameView = gameView;
this.extraDecisionDelayMillis = extraDecisionDelayMillis;
}
@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() {
final GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> value[0] = gameView.automationSnapshot());
return value[0];
}
@Override
public void waitForNextDecision(long millis) {
try {
Thread.sleep(millis + extraDecisionDelayMillis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Level-8 bot interrupted", interrupted);
}
}
}
}
@@ -0,0 +1,201 @@
package com.projectsacrifice.game;
/** Black-box level-8 scenarios driven only through the production input boundary. */
final class LevelEightScenario {
private static final long DECISION_MILLIS = 32L;
static final class Result {
private final boolean completed;
private final int deaths;
private final String diagnostic;
Result(boolean completed, int deaths, String diagnostic) {
this.completed = completed;
this.deaths = deaths;
this.diagnostic = diagnostic;
}
boolean completed() { return completed; }
int deaths() { return deaths; }
String diagnostic() { return diagnostic; }
}
Result recoverAfterClockExpires(LevelPlaytestBot.Driver driver, long timeoutMillis) {
long deadline = driver.currentTimeMillis() + timeoutMillis;
driver.startLevel(8);
GameView.AutomationSnapshot snapshot = crossPit(driver, deadline);
if (failed(snapshot, deadline, driver)) {
return failure("Cross the pit before the clock expires", snapshot);
}
snapshot = await(driver, deadline, "Wait for the far-side trap",
LevelPlaytestBot.Input.none(),
current -> current.playerX() >= 1_510f && !current.allDoorsOpen());
if (failed(snapshot, deadline, driver)) {
return failure("Wait for the far-side trap", snapshot);
}
snapshot = await(driver, deadline, "Return beneath the recovery relay",
LevelPlaytestBot.Input.left(),
current -> current.playerX() <= 1_680f && current.playerY() >= 779f);
if (failed(snapshot, deadline, driver)) {
return failure("Return beneath the recovery relay", snapshot);
}
snapshot = pulseLeftJumpThenAwait(driver, deadline, "Jump into the recovery relay",
current -> current.allDoorsOpen());
if (failed(snapshot, deadline, driver)) {
return failure("Jump into the recovery relay", snapshot);
}
snapshot = await(driver, deadline, "Land after rearming the clock",
LevelPlaytestBot.Input.none(), current -> current.playerY() >= 779f);
if (failed(snapshot, deadline, driver)) {
return failure("Land after rearming the clock", snapshot);
}
return finishFromFarSide(driver, deadline, snapshot, "Finish after recovery");
}
Result completeIntendedRoute(LevelPlaytestBot.Driver driver, long timeoutMillis) {
long deadline = driver.currentTimeMillis() + timeoutMillis;
driver.startLevel(8);
GameView.AutomationSnapshot snapshot = crossPit(driver, deadline);
if (failed(snapshot, deadline, driver)) {
return failure("Cross the pit before the clock expires", snapshot);
}
return finishFromFarSide(driver, deadline, snapshot, "Finish on the original timer");
}
private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver, long deadline) {
GameView.AutomationSnapshot snapshot = await(driver, deadline, "Trigger the left timer",
LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::allDoorsOpen);
if (failed(snapshot, deadline, driver)) return snapshot;
long timerStartedAt = snapshot.elapsedMilliseconds();
snapshot = await(driver, deadline, "Approach the pit",
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f);
if (failed(snapshot, deadline, driver)) return snapshot;
snapshot = await(driver, deadline, "Wait for the moving platform",
LevelPlaytestBot.Input.none(),
current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L);
if (failed(snapshot, deadline, driver)) return snapshot;
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
snapshot = await(driver, deadline, "Land on the moving platform",
LevelPlaytestBot.Input.right(),
current -> current.elapsedMilliseconds() >= timerStartedAt + 3_200L);
if (failed(snapshot, deadline, driver)) return snapshot;
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
snapshot = await(driver, deadline, "Jump from the moving platform to the far floor",
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f);
if (failed(snapshot, deadline, driver)) return snapshot;
snapshot = awaitFarFloorLandingOnOriginalTimer(driver, deadline);
if (failed(snapshot, deadline, driver)) return snapshot;
return await(driver, deadline, "Move under the relay after landing",
LevelPlaytestBot.Input.right(),
current -> current.playerX() >= 1_600f
&& current.playerY() >= 779f && current.allDoorsOpen());
}
private GameView.AutomationSnapshot awaitFarFloorLandingOnOriginalTimer(
LevelPlaytestBot.Driver driver, long deadline
) {
boolean moveLeft = true;
GameView.AutomationSnapshot snapshot = driver.snapshot();
while (driver.currentTimeMillis() < deadline && snapshot.playerAlive()
&& !(snapshot.playerX() >= 1_510f && snapshot.playerX() < 1_580f
&& snapshot.playerY() >= 779f && snapshot.allDoorsOpen())) {
driver.setInput(moveLeft, !moveLeft, false);
moveLeft = !moveLeft;
driver.waitForNextDecision(DECISION_MILLIS);
snapshot = driver.snapshot();
}
driver.setInput(false, false, false);
return snapshot;
}
private Result finishFromFarSide(LevelPlaytestBot.Driver driver, long deadline,
GameView.AutomationSnapshot snapshot, String step) {
snapshot = await(driver, deadline, "Reach the open-door jump",
LevelPlaytestBot.Input.right(),
current -> current.playerX() >= 1_730f && current.allDoorsOpen());
if (failed(snapshot, deadline, driver)) {
return failure("Reach the open-door jump", snapshot);
}
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
snapshot = await(driver, deadline, step, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed);
driver.setInput(false, false, false);
if (!snapshot.completed()) return failure(step, snapshot);
return new Result(true, snapshot.deaths(), step + " completed; " + state(snapshot));
}
private GameView.AutomationSnapshot pulseLeftJumpThenAwait(
LevelPlaytestBot.Driver driver, long deadline, String step,
LevelPlaytestBot.SnapshotCondition completion
) {
driver.setInput(true, false, true);
driver.waitForNextDecision(64L);
driver.setInput(true, false, false);
return await(driver, deadline, step, LevelPlaytestBot.Input.left(), completion);
}
private GameView.AutomationSnapshot await(
LevelPlaytestBot.Driver driver, long deadline, String step,
LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion
) {
GameView.AutomationSnapshot snapshot = driver.snapshot();
while (driver.currentTimeMillis() < deadline && snapshot.playerAlive()
&& !snapshot.completed() && !completion.reached(snapshot)) {
setInput(driver, input);
driver.waitForNextDecision(DECISION_MILLIS);
snapshot = driver.snapshot();
}
if (driver.currentTimeMillis() >= deadline || !snapshot.playerAlive()) {
driver.setInput(false, false, false);
}
return snapshot;
}
private static void setInput(LevelPlaytestBot.Driver driver, LevelPlaytestBot.Input input) {
if (input == LevelPlaytestBot.Input.right()) {
driver.setInput(false, true, false);
} else if (input == LevelPlaytestBot.Input.left()) {
driver.setInput(true, false, false);
} else if (input == LevelPlaytestBot.Input.rightJump()) {
driver.setInput(false, true, true);
} else {
driver.setInput(false, false, false);
}
}
private static boolean failed(GameView.AutomationSnapshot snapshot, long deadline,
LevelPlaytestBot.Driver driver) {
return !snapshot.completed()
&& (!snapshot.playerAlive() || driver.currentTimeMillis() >= deadline);
}
private static Result failure(String step, GameView.AutomationSnapshot snapshot) {
return new Result(false, snapshot.deaths(), step + " failed; " + state(snapshot));
}
private static String state(GameView.AutomationSnapshot snapshot) {
return "x=" + snapshot.playerX()
+ ", y=" + snapshot.playerY()
+ ", alive=" + snapshot.playerAlive()
+ ", doorOpen=" + snapshot.allDoorsOpen()
+ ", completed=" + snapshot.completed()
+ ", elapsedMs=" + snapshot.elapsedMilliseconds()
+ ", deaths=" + snapshot.deaths();
}
}
@@ -12,6 +12,8 @@ import org.junit.runner.RunWith;
/** Acceptance test: a bot must finish the same level implementation shipped in the APK. */
@RunWith(AndroidJUnit4.class)
public final class LevelFivePlaythroughTest {
private static final int CONSECUTIVE_RUNS = 3;
@Test
public void testBotCompletesLevelFive() {
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
@@ -19,15 +21,20 @@ public final class LevelFivePlaythroughTest {
scenario.onActivity(activity -> view[0] = activity.gameViewForAutomation());
LevelPlaytestBot bot = new LevelPlaytestBot();
LevelPlaytestBot.Result result = bot.proveLevelFive(
new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]),
25_000L
);
AndroidDriver driver = new AndroidDriver(
InstrumentationRegistry.getInstrumentation(), view[0]);
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
LevelPlaytestBot.Result result = bot.proveLevelFive(driver, 25_000L);
org.junit.Assert.assertTrue(result.diagnostic(), result.completed());
org.junit.Assert.assertTrue("The solution must use at least one volunteer", result.deaths() >= 1);
org.junit.Assert.assertTrue("The delivered corpse must remain in the puzzle", result.corpseCount() >= 1);
org.junit.Assert.assertTrue("The pressure-plate door must open", result.doorOpened());
org.junit.Assert.assertTrue("run=" + run + "; " + result.diagnostic(),
result.completed());
org.junit.Assert.assertEquals("run=" + run + "; exactly one volunteer is required",
1, result.deaths());
org.junit.Assert.assertEquals("run=" + run + "; delivered corpse must remain",
1, result.corpseCount());
org.junit.Assert.assertTrue("run=" + run + "; pressure-plate door must open",
result.doorOpened());
}
}
}
@@ -0,0 +1,96 @@
package com.projectsacrifice.game;
/** Declarative intended-route scenarios for the first four teaching levels. */
public final class EarlyLevelScenarios {
private EarlyLevelScenarios() {}
public static LevelPlaytestBot.Scenario levelOne() {
return LevelPlaytestBot.Scenario.builder(1, "Level 1: movement and jump")
.step("Approach the first ledge", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 500f)
.step("Jump over the first ledge", 3_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 900f)
.step("Release jump before the upper ledge", 1_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 950f)
.step("Jump over the upper ledge", 3_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 1_330f)
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
public static LevelPlaytestBot.Scenario levelTwo() {
return LevelPlaytestBot.Scenario.builder(2, "Level 2: three-corpse bridge")
.step("Place the first bridge body", 4_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() >= 1)
.step("Wait for the second volunteer", 2_000L, LevelPlaytestBot.Input.none(),
EarlyLevelScenarios::respawnedAtStart)
.step("Approach the first bridge body", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 470f)
.step("Jump to the middle of the spikes", 2_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 730f)
.step("Place the second bridge body", 2_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.deaths() >= 2)
.step("Wait for the third volunteer", 2_000L, LevelPlaytestBot.Input.none(),
EarlyLevelScenarios::respawnedAtStart)
.step("Return to the corpse bridge", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 470f)
.step("Jump to the far side of the spikes", 2_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 750f)
.step("Step off the middle bridge body", 2_000L, LevelPlaytestBot.Input.left(),
snapshot -> snapshot.playerX() <= 690f)
.step("Place the third bridge body", 2_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.deaths() >= 3)
.step("Wait for the crossing volunteer", 2_000L, LevelPlaytestBot.Input.none(),
EarlyLevelScenarios::respawnedAtStart)
.step("Approach the completed bridge", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 470f)
.step("Cross the corpse bridge", 3_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 900f)
.step("Release jump before the final ledge", 2_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 1_030f)
.step("Jump over the final ledge", 3_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 1_450f)
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
public static LevelPlaytestBot.Scenario levelThree() {
return LevelPlaytestBot.Scenario.builder(3, "Level 3: saw corpse pressure signal")
.step("Leave a volunteer at the saw", 4_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() >= 1)
.step("Prove the corpse opens the pressure door", 3_000L, LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::allDoorsOpen)
.step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(),
EarlyLevelScenarios::respawnedAtStart)
.step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 390f)
.step("Jump over the saw and plate body", 3_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 700f)
.step("Reach the relic through the open door", 5_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
public static LevelPlaytestBot.Scenario levelFour() {
return LevelPlaytestBot.Scenario.builder(4, "Level 4: toggle lift to gallery")
.step("Cross the toggle once and activate the lift", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 800f)
.step("Jump onto the moving lift", 3_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 1_050f && snapshot.playerY() < 690f)
.step("Ride the lift to its upper phase", 7_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerY() <= 330f)
.step("Move to the lift's right edge", 2_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 1_225f)
.step("Jump from the lift to the upper gallery", 3_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 1_610f && snapshot.playerY() <= 400f)
.step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
private static boolean respawnedAtStart(GameView.AutomationSnapshot snapshot) {
return snapshot.playerAlive() && snapshot.playerX() < 250f;
}
}
@@ -53,11 +53,23 @@ public final class GameView extends View implements Runnable {
private final float playerY;
private final int deaths;
private final int corpseCount;
private final float firstMovingPlatformY;
private final boolean playerOnMovingPlatform;
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
long elapsedMilliseconds,
float playerX, float playerY, int deaths, int corpseCount) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
Float.NaN, false);
}
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
long elapsedMilliseconds,
float playerX, float playerY, int deaths, int corpseCount,
float firstMovingPlatformY, boolean playerOnMovingPlatform) {
this.completed = completed;
this.playerAlive = playerAlive;
this.allDoorsOpen = allDoorsOpen;
@@ -69,6 +81,8 @@ public final class GameView extends View implements Runnable {
this.playerY = playerY;
this.deaths = deaths;
this.corpseCount = corpseCount;
this.firstMovingPlatformY = firstMovingPlatformY;
this.playerOnMovingPlatform = playerOnMovingPlatform;
}
public boolean completed() { return completed; }
@@ -82,6 +96,8 @@ public final class GameView extends View implements Runnable {
public float playerY() { return playerY; }
public int deaths() { return deaths; }
public int corpseCount() { return corpseCount; }
public float firstMovingPlatformY() { return firstMovingPlatformY; }
public boolean playerOnMovingPlatform() { return playerOnMovingPlatform; }
}
private static final class Player {
@@ -1128,6 +1144,17 @@ public final class GameView extends View implements Runnable {
if (level != null) {
for (LevelDefinition.Door door : level.doors) allOpen &= doorOpen(door);
}
float firstMovingPlatformY = Float.NaN;
for (PlatformState state : platformStates) {
if (state.definition.isMoving()) {
firstMovingPlatformY = state.y;
break;
}
}
boolean playerOnMovingPlatform = player.alive
&& player.supportIndex >= 0
&& player.supportIndex < platformStates.size()
&& platformStates.get(player.supportIndex).definition.isMoving();
return new AutomationSnapshot(
screen == Screen.COMPLETE,
player.alive,
@@ -1139,7 +1166,9 @@ public final class GameView extends View implements Runnable {
player.x,
player.y,
deaths,
corpses.size()
corpses.size(),
firstMovingPlatformY,
playerOnMovingPlatform
);
}
}
@@ -90,7 +90,7 @@ public final class LevelCatalog {
6, "Cold Comfort", "The frost makes a solid block. Use the frozen volunteer as a step.",
2150, 130, 760, goal(1880, 500), 1, 65,
platforms(floor(0, 1120), ledge(810, 590, 520), ledge(1430, 610, 570)),
hazards(hazard(FREEZER, 690, 750, 100, 130)), switches(), doors()
hazards(hazard(FREEZER, 690, 776, 100, 104)), switches(), doors()
);
}
@@ -106,14 +106,17 @@ public final class LevelCatalog {
private static LevelDefinition level8() {
return level(
8, "Borrowed Time", "The blue switch lasts five seconds. Keep moving.",
8, "Borrowed Time", "Jump for the far blue relay if the timed gate closes.",
2450, 130, 760, goal(2220, 600), 0, 38,
platforms(
floor(0, 900), floor(1510, 940), ledge(1980, 710, 470),
floor(0, 970), floor(1510, 940), ledge(1980, 710, 470),
moving(990, 770, 240, 34, 380, 0, 4.0f, "clock")
),
hazards(hazard(PIT, 900, 880, 610, 200)),
switches(sw(TIMED, 560, 810, 90, 70, "clock", 5.5f, 1)),
hazards(hazard(PIT, 970, 880, 540, 200)),
switches(
sw(TIMED, 560, 810, 90, 70, "clock", 6.0f, 1),
sw(TIMED, 1600, 650, 90, 70, "clock", 3.5f, 1)
),
doors(door(1770, 560, 82, 320, "clock"))
);
}
@@ -137,14 +140,14 @@ public final class LevelCatalog {
10, "The Last Procession", "Launch, freeze, conduct, ascend. Every lesson opens the last relic.",
3100, 130, 760, goal(2880, 330), 3, 120,
platforms(
floor(0, 1880), floor(2140, 960), ledge(2420, 440, 680),
floor(0, 1940), floor(2140, 960), ledge(2420, 440, 680),
moving(1840, 790, 250, 34, 0, -410, 5.0f, "lift")
),
hazards(
hazard(SAW, 520, 760, 96, 96, null, 700, -240),
hazard(FREEZER, 1110, 750, 100, 130),
hazard(ELECTRIC, 1510, 780, 110, 100, "power", 0, 0),
hazard(PIT, 1880, 880, 260, 200)
hazard(PIT, 1940, 880, 200, 200)
),
switches(sw(PRESSURE, 860, 860, 160, 20, "lift", 0, 1)),
doors(door(2310, 310, 82, 570, "power"))
@@ -82,6 +82,53 @@ public final class LevelCatalogTest {
assertFalse(finale.doors.isEmpty());
}
@Test
public void levelSixFreezerKillsOnTheFloorButNotOnTopOfItsFrozenBlock() {
LevelDefinition level = LevelCatalog.get(5);
LevelDefinition.Hazard freezer = null;
for (LevelDefinition.Hazard hazard : level.hazards) {
if (hazard.type == LevelDefinition.HazardType.FREEZER) freezer = hazard;
}
assertTrue("Level 6 must contain its freezer", freezer != null);
float frozenBlockTop = 880f - 104f;
assertTrue("A floor-running volunteer must still hit the freezer",
GameMath.intersects(688f, 780f, 62f, 100f,
freezer.box.x, freezer.box.y, freezer.box.width, freezer.box.height));
assertFalse("A volunteer standing on the frozen block must clear the freezer",
GameMath.intersects(688f, frozenBlockTop - 100f, 62f, 100f,
freezer.box.x, freezer.box.y, freezer.box.width, freezer.box.height));
}
@Test
public void masteryLiftHasLaunchOverlapAndStillSpansARealPit() {
LevelDefinition level = LevelCatalog.get(9);
LevelDefinition.Platform launchFloor = level.platforms.get(0);
LevelDefinition.Platform landingFloor = level.platforms.get(1);
LevelDefinition.Platform lift = null;
LevelDefinition.Hazard pit = null;
for (LevelDefinition.Platform platform : level.platforms) {
if (platform.isMoving()) lift = platform;
}
for (LevelDefinition.Hazard hazard : level.hazards) {
if (hazard.type == LevelDefinition.HazardType.PIT) pit = hazard;
}
assertTrue("Level 10 must contain its moving lift", lift != null);
assertTrue("Level 10 must retain its final pit", pit != null);
float launchOverlap = Math.min(launchFloor.box.right(), lift.box.right())
- Math.max(launchFloor.box.x, lift.box.x);
assertTrue("The floor must overlap the lift by at least 100px for a stable launch",
launchOverlap >= 100f);
assertEquals("The pit must begin exactly where the launch floor ends",
launchFloor.box.right(), pit.box.x, 0f);
assertEquals("The pit must continue to the landing floor",
landingFloor.box.x, pit.box.right(), 0f);
assertTrue("The final pit must remain at least 200px wide", pit.box.width >= 200f);
assertTrue("The upper gallery must still require the vertical lift",
lift.travelY <= -400f && level.goal.y < launchFloor.box.y - 300f);
}
@Test
public void everyDoorAndControlledPlatformHasADeclaredSignalSource() {
for (LevelDefinition level : LevelCatalog.all()) {
@@ -25,6 +25,25 @@ public final class LevelPlaytestBotTest {
assertEquals("Reach the goal", result.lastStep());
}
@Test
public void sameDriverCanRestartTheCompleteScenarioThreeTimes() {
ScriptedDriver driver = new ScriptedDriver();
LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario
.builder(7, "Repeated scenario")
.step("Create a corpse", 1_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() >= 1)
.step("Reach the goal", 1_000L, LevelPlaytestBot.Input.left(),
GameView.AutomationSnapshot::completed)
.build();
for (int pass = 1; pass <= 3; pass++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L);
assertTrue("Pass " + pass + ": " + result.diagnostic(), result.completed());
assertEquals("Pass " + pass, "Reach the goal", result.lastStep());
assertEquals("Pass " + pass, 1, result.deaths());
}
}
@Test
public void stepTimeoutReportsItsNameAndObservableSnapshot() {
ScriptedDriver driver = new ScriptedDriver(false);
@@ -61,6 +80,16 @@ public final class LevelPlaytestBotTest {
assertTrue(result.doorOpened());
}
@Test
public void automationSnapshotDefaultsToNoMovingPlatform() {
GameView.AutomationSnapshot snapshot = new GameView.AutomationSnapshot(
false, true, false, false, false,
1, 0L, 130f, 760f, 0, 0);
assertTrue(Float.isNaN(snapshot.firstMovingPlatformY()));
assertFalse(snapshot.playerOnMovingPlatform());
}
private static final class ScriptedDriver implements LevelPlaytestBot.Driver {
private int startedLevel;
private long nowMillis;
@@ -81,6 +110,11 @@ public final class LevelPlaytestBotTest {
@Override
public void startLevel(int oneBasedLevelNumber) {
startedLevel = oneBasedLevelNumber;
nowMillis = 0L;
moveLeft = false;
moveRight = false;
deaths = 0;
completed = false;
}
@Override
+16 -10
View File
@@ -13,10 +13,15 @@
- смерть не сбрасывает состояние уровня, тела остаются физическими объектами;
- реализованы шипы, пила, нажимные и временные кнопки, двери, движущиеся платформы, заморозка и электричество;
- сохраняются прогресс, лучшее время и минимальное число жертв;
- уровень 5 защищён Android acceptance-тестом: бот проходит реальную игровую сцену примерно за 8,2 секунды с одной смертью и одним оставшимся телом;
- intended route всех уровней 1–10 защищён Android acceptance-ботами: каждый маршрут выполняется по три раза через production-ввод без прямого перемещения персонажа;
- blocker-softlock уровня 8 после истечения таймера устранён recovery relay и покрыт отдельным трёхкратным сценарием;
- исправлено перекрытие `FREEZER` уровня 6, которое повторно убивало игрока на frozen stepping stone;
- final integrated Android 16 matrix: 14/14 методов, 35 игровых выполнений и lifecycle-проверка; wall time `5:59`;
- финальный debug APK проверен на Android 16: установка, холодный запуск, ZIP-структура и v1/v2-подписи корректны.
Главный текущий риск: автоматическая проходимость доказана только для уровня 5. Остальные уровни могут содержать тупики, нестабильную физику или слишком точные действия.
Главный текущий риск: автоматическая проходимость доказана, но ручная понятность и баланс этапа 2 ещё не подтверждены. Отдельно остаются гипотезы об альтернативных решениях уровня 2 и обходе frozen corpse на уровне 10.
Performance-оговорка: критерий этапа 1 «полный прогон до 5 минут» пока не выполнен — финальный зелёный matrix занимает `5:59`. Сокращать количество трёхкратных прохождений или скрывать нестабильность retries запрещено; требуется отдельная оптимизация тестового runner.
## 2. Принципы разработки
@@ -168,14 +173,15 @@
8. холодный запуск и проверка crash-буфера;
9. ручной smoke-тест уровня 1 и одного сложного комбинированного уровня.
## 5. Ближайшая итерация
## 5. Текущая итерация
Следующая итерация ограничивается этапом 1:
Этап 1 завершён автоматическим доказательством intended route уровней 1–10. Текущая итерация ограничивается этапом 2:
- обобщить существующего бота уровня 5;
- добавить ботов уровней 1–4;
- затем добавить ботов уровней 6–10;
- зафиксировать единый отчёт `10/10 passed`;
- не добавлять уровень 11 и новые механики до выполнения этого критерия.
- выполнить полный integrated `10/10 passed` перед каждым APK;
- провести по две ручные сессии каждого уровня и зафиксировать попытки, restart и непонятные места;
- скорректировать допуски прыжков и подсказки только по наблюдаемым проблемам;
- проверить чистый restart из ключевых промежуточных состояний;
- закрыть или документировать гипотезы альтернативных решений уровней 2 и 10;
- не добавлять уровень 11 и новые механики до завершения критериев этапа 2.
Результатом итерации должны стать обновлённый исходный проект, проверенный APK и отчёт с длительностью и результатом каждого сценария.
Результатом итерации должны стать обновлённый исходный проект, проверенный APK, integrated bot report и протокол ручных игровых сессий.
+44 -37
View File
@@ -1,8 +1,8 @@
# Графы решений уровней 1–10
Документ фиксирует **предполагаемый авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, а не из ручного прохождения. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения.
Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения.
На 18 августа 2026 года автоматически доказан только уровень 5: `LevelFivePlaythroughTest` управляет production `GameView` через те же флаги движения и прыжка, что и touch-контролы. Все остальные графы имеют статус «предполагаемый, не доказан ботом» до появления воспроизводимого `connectedDebugAndroidTest`.
На 18 августа 2026 года intended route всех уровней 1–10 автоматически выполнен по три раза подряд без retry masking. `EarlyLevelPlaythroughTest`, `LevelFivePlaythroughTest`, `LateLevelPlaythroughTest` и `LevelEightPlaythroughTest` управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`.
## Семантика, общая для графов
@@ -17,7 +17,7 @@
## Уровень 1 — `The Volunteer`
**Статус:** предполагаемый маршрут, ботом не доказан.
**Статус:** доказан ботом, 3/3 последовательных прохождения без смертей.
**Ожидаемые смерти:** `0` (`targetDeaths=0`).
**Источник:** `LevelCatalog.level1()`; уступы `(640,760,230)` и `(1060,690,250)`; `goal=(1690,770,86,110)`.
@@ -36,11 +36,11 @@ Goal расположен справа от двух перекрывающих
**Bot checkpoints:** `playerX>640` при `playerY≤660` → `playerX>1060` при `playerY≤590` → `playerX>1310` → `completed=true`, `deaths=0`.
**Softlock/неопределённость:** blocker-softlock по данным модели не ожидается; точные окна двух прыжков и возможность альтернативного прохода под/мимо уступов автоматически не проверены.
**Softlock/неопределённость:** intended route и окна двух прыжков воспроизводимо подтверждены. Альтернативный проход под/мимо уступов отдельно не проверялся.
## Уровень 2 — `A Useful End`
**Статус:** предполагаемый маршрут, ботом не доказан.
**Статус:** доказан ботом, 3/3 последовательных прохождения с тремя телами.
**Ожидаемые смерти:** `3` (`targetDeaths=3`).
**Источник:** `LevelCatalog.level2()`; `SPIKES=(610,850,260,30)`; статичный уступ `(1180,740,240)`; `goal=(1830,770,86,110)`.
@@ -61,11 +61,11 @@ Spike lane x=610..870 перекрывает наземный маршрут
**Bot checkpoints:** после каждой предусмотренной жертвы `deaths/corpseCount=1/1`, `2/2`, `3/3`; затем живой игрок `playerX>870`; финально `completed=true`, `deaths=3`.
**Softlock/неопределённость:** тела становятся горизонтальными коллайдерами и требуют прыжка уже после первой смерти. При текущих `RUN_SPEED`/`JUMP_SPEED` ширина lane может допускать перепрыгивание с меньшим числом смертей; это возможный sequence break, а не подтверждённый маршрут. Нужны бот-проверка intended route и отдельный negative/alternative-solution тест.
**Softlock/неопределённость:** intended route с тремя телами воспроизводимо подтверждён. Отдельная попытка прямого zero-death прохода не подтвердила sequence break, но не является исчерпывающим negative-тестом; минимальность трёх смертей остаётся открытым follow-up.
## Уровень 3 — `Dead Weight`
**Статус:** предполагаемый маршрут, ботом не доказан.
**Статус:** доказан ботом, 3/3 последовательных прохождения с одной жертвой.
**Ожидаемые смерти:** `1` (`targetDeaths=1`).
**Источник:** `LevelCatalog.level3()`; `SAW=(565,760,92,92)`, impulse `(0,-150)`; `PRESSURE=(540,860,150,20)`, `signalId="gate"`; дверь `(1110,570,78,310)` требует `"gate"`.
@@ -87,11 +87,11 @@ Door закрывает путь к goal
**Bot checkpoints:** `deaths=1`, `corpseCount=1`; после respawn `playerAlive=true`; требуется расширить snapshot состоянием `"gate"` либо использовать `allDoorsOpen=true`; затем `playerX>1188` и `completed=true`.
**Softlock/неопределённость:** успех зависит от фактической точки первого пересечения игрока с `SAW`, потому что тело сохраняет `player.x`; достаточное перекрытие с плитой ботом ещё не доказано. Если тело не удержит плиту, продолжение невозможно без дополнительной жертвы или restart.
**Softlock/неопределённость:** доставка тела на плиту, удержание двери и последующий проход подтверждены в трёх последовательных запусках. Альтернативные точки контакта с `SAW` отдельно не перебирались.
## Уровень 4 — `The Long Lift`
**Статус:** предполагаемый маршрут, ботом не доказан.
**Статус:** доказан ботом, 3/3 последовательных прохождения без смертей.
**Ожидаемые смерти:** `0` (`targetDeaths=0`).
**Источник:** `LevelCatalog.level4()`; `TOGGLE=(520,820,72,60)`, `signalId="lift"`; moving platform `(1040,790,260,34)`, travel `(0,-390)`, period `5.2`, `controlId="lift"`; `PIT=(1500,880,320,200)`; верхний уступ `(1660,500,540)`; `goal=(1960,390,86,110)`.
@@ -112,12 +112,12 @@ Goal находится на верхней галерее за PIT
**Bot checkpoints:** `signal "lift"=true`; координата платформы уменьшается от `y=790`; игрок получает `supportIndex` движущейся платформы; затем `playerX≥1660`, `playerY≤400`; финально `completed=true`, `deaths=0`. Для этого snapshot нужно расширить сигналами, `supportIndex` и состоянием платформы.
**Softlock/неопределённость:** повторный контакт с `TOGGLE` выключит сигнал и заморозит платформу в текущей фазе. Прыжок от правого края платформы к началу галереи имеет небольшой геометрический запас и не подтверждён ботом; это риск точного тайминга/непроходимости.
**Softlock/неопределённость:** lift→gallery прыжок подтверждён в трёх последовательных запусках. Повторный контакт с `TOGGLE` по-прежнему выключает сигнал и может заморозить платформу; это предусмотренное состояние, доступное для restart.
## Уровень 5 — `Express Delivery`
**Статус:** автоматически доказан `LevelFivePlaythroughTest` на production `GameView`.
**Ожидаемые смерти:** `1` (`targetDeaths=1`); acceptance-тест требует минимум одну.
**Статус:** доказан `LevelFivePlaythroughTest`, 3/3 последовательных прохождения.
**Ожидаемые смерти:** `1` (`targetDeaths=1`); acceptance-тест требует ровно одну.
**Источник:** `LevelCatalog.level5()`; `SAW=(570,760,96,96)`, impulse `(500,-700)`; уступ `(850,800,330)`; `PRESSURE=(930,780,160,20)`, `signalId="gate"`; дверь `(1390,560,82,320)` требует `"gate"`; `goal=(2080,770,86,110)`.
### Solution graph 5
@@ -140,13 +140,13 @@ Door закрывает путь к goal
**Bot checkpoints:** реализованы в `LevelPlaytestBot.proveLevelFive`: первая смерть; `corpseCount≥1`; `allDoorsOpen=true`; прыжок после `playerX≥390`, дополнительный прыжок после `playerX≥750`; затем `completed=true`. Тест проверяет `completed`, смерть, сохранённое тело и факт открытия двери.
**Softlock/неопределённость:** основной маршрут воспроизводимо подтверждён одним device-level acceptance-тестом, но пока не выполняется требование roadmap о трёх последовательных прогонах. Альтернативные решения, restart и физическая стабильность на нескольких frame rates этим тестом не покрыты.
**Softlock/неопределённость:** основной маршрут воспроизводимо подтверждён тремя последовательными запусками. Альтернативные решения и физическая стабильность на нескольких frame rates этим тестом не покрыты.
## Уровень 6 — `Cold Comfort`
**Статус:** предполагаемый маршрут, ботом не доказан.
**Статус:** доказан ботом, 3/3 последовательных прохождения с frozen stepping stone.
**Ожидаемые смерти:** `1` (`targetDeaths=1`).
**Источник:** `LevelCatalog.level6()`; `FREEZER=(690,750,100,130)`; floor заканчивается на `x=1120`; уступы `(810,590,520)` и `(1430,610,570)`; `goal=(1880,500,86,110)`.
**Источник:** `LevelCatalog.level6()`; `FREEZER=(690,776,100,104)`; floor заканчивается на `x=1120`; уступы `(810,590,520)` и `(1430,610,570)`; `goal=(1880,500,86,110)`.
### Solution graph 6
@@ -166,11 +166,11 @@ Door закрывает путь к goal
**Bot checkpoints:** `deaths=1`, `corpseCount=1`; после respawn игрок стоит на блоке около `playerY=676`; затем на первом уступе `playerY=490`, `playerX≥810`; на втором `playerY=510`, `playerX≥1430`; финально `completed=true`.
**Softlock/неопределённость:** маршрут зависит от возможности запрыгнуть на блок рядом с активным `FREEZER`, а затем набрать высоту до уступа; оба перехода ботом не проверены. Frozen block фиксирован детерминированно, поэтому физический дрейф тела не ожидается.
**Softlock/неопределённость:** первоначальная зона `FREEZER` перекрывала игрока, стоящего на блоке, на 26 px и делала intended route невозможным. Высота зоны минимально исправлена до `104`; floor runner всё ещё погибает, а оба прыжка подтверждены тремя запусками.
## Уровень 7 — `Live Wire`
**Статус:** предполагаемый маршрут, ботом не доказан.
**Статус:** доказан ботом, 3/3 последовательных прохождения с conductive corpse.
**Ожидаемые смерти:** `1` (`targetDeaths=1`).
**Источник:** `LevelCatalog.level7()`; `ELECTRIC=(650,780,110,100)`, `relayId="power"`; дверь `(1130,560,84,320)` требует `"power"`; `goal=(1930,770,86,110)`.
@@ -193,37 +193,44 @@ Door закрывает путь к goal
**Bot checkpoints:** `deaths=1`, `corpseCount=1`; `allDoorsOpen=true` после появления conductive corpse; после respawn `playerX>1214`; финально `completed=true`, `deaths=1`. Для прямой проверки механики snapshot должен отдельно сообщать conductive state или активный `"power"`.
**Softlock/неопределённость:** позиция relay-тела задаётся детерминированно, поэтому потеря сигнала не ожидается. Не доказано, что следующий игрок стабильно перепрыгивает совмещённые hazard/body без дополнительной смерти.
**Softlock/неопределённость:** relay-сигнал и прыжок через совмещённые hazard/body подтверждены тремя запусками ровно с одной смертью.
## Уровень 8 — `Borrowed Time`
**Статус:** предполагаемый маршрут, ботом не доказан.
**Статус:** intended route и восстановление после истечения таймера доказаны production-input ботом, по три раза подряд.
**Ожидаемые смерти:** `0` (`targetDeaths=0`).
**Источник:** `LevelCatalog.level8()`; `TIMED=(560,810,90,70)`, `signalId="clock"`, duration `5.5`; moving platform `(990,770,240,34)`, travel `(380,0)`, period `4.0`, `controlId="clock"`; `PIT=(900,880,610,200)`; дверь `(1770,560,82,320)` требует `"clock"`; `goal=(2220,600,86,110)`.
**Источник:** `LevelCatalog.level8()`; основной `TIMED=(560,810,90,70)`, `signalId="clock"`, duration `6.0`; recovery relay `TIMED=(1600,650,90,70)`, duration `3.5`; moving platform `(990,770,240,34)`, travel `(380,0)`, period `4.0`, `controlId="clock"`; `PIT=(970,880,540,200)`; дверь `(1770,560,82,320)` требует `"clock"`; `goal=(2220,600,86,110)`.
### Solution graph 8
```text
PIT разрывает floor, а Door закрывает дальнейший путь
→ войти в TIMED и запустить signal "clock" на 5.5 s
→ войти в TIMED и запустить signal "clock" на 6.0 s
→ "clock" одновременно запускает horizontal moving platform и открывает Door
→ без задержки добежать до края floor x=900
→ без задержки добежать до края floor x=970
→ прыгнуть на moving platform и пересечь PIT
→ сойти на правый floor x≥1510
→ пройти Door x=1770..1852 до истечения "clock"
→ запрыгнуть на ledge x=1980..2450
→ пересечь goal и получить COMPLETE без смерти
Ветка восстановления после пересечения PIT:
→ дождаться истечения "clock" на правом floor x≥1510
→ Door закрывается и подтверждает прежнюю trap-state
→ намеренно прыгнуть в высокий recovery relay x=1600, y=650
→ повторно запустить "clock" на 3.5 s
→ пройти Door и достичь goal без смерти/restart
```
**Механика и сигналы:** фронт контакта `TIMED` → `timedSignals["clock"]=5.5` → движение платформы по `travelX=380` и открытие Door; таймер уменьшается каждый update.
**Механика и сигналы:** фронт контакта основного `TIMED` → `timedSignals["clock"]=6.0` → движение платформы по `travelX=380` и открытие Door; высокий relay выставляет тот же сигнал на `3.5 s`. Обычный бег проходит под relay, поэтому original route не активирует восстановление автоматически. Строгий grounded-checkpoint выявил нестабильное закрытие Door на `5.5 s`; добавленные `0.5 s` дают запас на проход двери. Android 16 при задержках между решениями бота выявил отдельное падение с узкого стартового края: floor продлён на `70` px, а начало PIT сдвинуто с `900` на `970` без изменения его правого края `1510`, платформы, таймеров или recovery relay.
**Bot checkpoints:** `"clock"` active с remaining time; platform `x>990`; игрок получает её `supportIndex`; затем `playerX≥1510`; Door остаётся open при `playerX>1852`; финально `completed=true`, `deaths=0`. Требуется добавить в snapshot remaining time и координаты платформы.
**Bot checkpoints:** основной timer активен; игрок двумя прыжками пересекает PIT через moving platform; перед движением к relay явно подтверждены far floor `playerX≥1510`, `playerY≥779` и всё ещё открытая Door. Intended route проходит Door на первом таймере. Recovery route ждёт `allDoorsOpen=false` справа от PIT, прыжком получает `allDoorsOpen=true`, проходит Door и завершает уровень. Оба маршрута финально дают `completed=true`, `deaths=0`; каждый выполнен три раза подряд на Android 16. Дополнительный intended run с `96 ms` задержки на каждое решение также завершён без смерти. Попытка расширить платформу была отклонена тестом: она могла унести ожидающего игрока с дальнего floor обратно над PIT после закрытия двери.
**Softlock/неопределённость:** если `"clock"` истечёт после пересечения PIT, Door закроется, платформа замрёт, а переключатель останется на левой стороне; это потенциальный blocker-softlock до restart. Достаточность 5.5 s и синхронизация прыжка на платформу ботом не доказаны.
**Softlock/неопределённость:** прежний blocker устранён: после истечения `"clock"` справа от PIT высокий relay гарантирует restart-free повторное открытие двери. Relay не помогает слева от PIT и не отменяет timed-platform crossing. Оба пути проверены без прямой мутации состояния, teleport, auto-complete или restart.
## Уровень 9 — `Two Good Reasons`
**Статус:** предполагаемый маршрут, ботом не доказан.
**Статус:** доказан ботом, 3/3 последовательных прохождения с AND из двух тел.
**Ожидаемые смерти:** `2` (`targetDeaths=2`).
**Источник:** `LevelCatalog.level9()`; `SPIKES=(520,850,130,30)`; `PRESSURE=(510,842,160,38)`, `signalId="weight"`; `ELECTRIC=(910,780,110,100)`, `relayId="power"`; дверь `(1510,540,86,340)` требует `"weight"` и `"power"`; `goal=(2310,770,86,110)`.
@@ -246,13 +253,13 @@ Final Door использует AND из "weight" и "power"
**Bot checkpoints:** после первой смерти `deaths/corpses=1/1`, `"weight"=true`, Door ещё closed; после второй `2/2`, `"power"=true`, `allDoorsOpen=true`; после respawn `playerX>1596`; финально `completed=true`, `deaths=2`. Snapshot нужно расширить поимёнными сигналами, чтобы доказать AND, а не только открытие двери.
**Softlock/неопределённость:** первое тело сохраняет `player.x` в момент контакта со `SPIKES`; нужно подтвердить, что его пересечение с узкой общей зоной PRESSURE стабильно. Если `"weight"` не удерживается, дополнительная spike-смерть может изменить счётчик, но не гарантирует предусмотренное решение.
**Softlock/неопределённость:** первая смерть стабильно удерживает `"weight"`, но дверь остаётся закрытой; вторая активирует `"power"` и открывает AND-door. Цепочка подтверждена тремя запусками ровно с двумя телами.
## Уровень 10 — `The Last Procession`
**Статус:** предполагаемый mastery-маршрут, ботом не доказан.
**Ожидаемые смерти:** `3` (`targetDeaths=3`).
**Источник:** `LevelCatalog.level10()`; `SAW=(520,760,96,96)`, impulse `(700,-240)`; `PRESSURE=(860,860,160,20)`, `signalId="lift"`; `FREEZER=(1110,750,100,130)`; `ELECTRIC=(1510,780,110,100)`, `relayId="power"`; moving platform `(1840,790,250,34)`, travel `(0,-410)`, period `5.0`, `controlId="lift"`; `PIT=(1880,880,260,200)`; дверь `(2310,310,82,570)` требует `"power"`; верхний уступ `(2420,440,680)`; `goal=(2880,330,86,110)`.
**Статус:** mastery-маршрут доказан production-input ботом: 3/3 последовательных прохождения и ограниченный стресс финального перехода `96 ms` без ретраев.
**Ожидаемые и доказанные смерти:** `3` (`targetDeaths=3`).
**Источник:** `LevelCatalog.level10()`; `SAW=(520,760,96,96)`, impulse `(700,-240)`; `PRESSURE=(860,860,160,20)`, `signalId="lift"`; `FREEZER=(1110,750,100,130)`; `ELECTRIC=(1510,780,110,100)`, `relayId="power"`; moving platform `(1840,790,250,34)`, travel `(0,-410)`, period `5.0`, `controlId="lift"`; стартовый floor заканчивается на `x=1940`; `PIT=(1940,880,200,200)`; дверь `(2310,310,82,570)` требует `"power"`; верхний уступ `(2420,440,680)`; `goal=(2880,330,86,110)`.
### Solution graph 10
@@ -274,14 +281,14 @@ Goal расположен на верхнем уступе за PIT и закр
**Механика и сигналы:** launched saw corpse → `"lift"` → vertical platform; frozen corpse → твёрдая ступень; conductive corpse → `"power"` → Door open; platform и Door используют независимые постоянные сигналы от сохранённых тел.
**Bot checkpoints:** после SAW `deaths=1`, `"lift"=true`, platform `y<790`; после FREEZER `deaths=2` и наличие frozen corpse; после ELECTRIC `deaths=3`, `"power"=true`, `allDoorsOpen=true`; затем игрок на moving-platform `supportIndex`, в верхней фазе `playerY≤280`; после прыжка `playerX≥2420`, `playerY≤340`; финально `completed=true`. Snapshot должен различать типы тел, оба сигнала, platform position/support.
**Bot checkpoints:** после SAW `deaths=1`, `"lift"=true`, platform `y<790`; после FREEZER `deaths=2` и наличие frozen corpse; после ELECTRIC `deaths=3`, `"power"=true`, `allDoorsOpen=true`; перед посадкой первая moving platform должна вернуться к `y≥780`, затем snapshot подтверждает `playerOnMovingPlatform=true`; в верхней фазе `playerY≤280`; после прыжка `playerX≥2420`, `playerY≤340`; финально `completed=true`.
**Softlock/неопределённость:** обязательность frozen corpse не следует однозначно из текущей геометрии — `FREEZER` потенциально можно перепрыгнуть, сохранив только две необходимые сигнальные жертвы. Доставка saw-тела на плиту зависит от динамического торможения, а прыжок с платформы к ledge имеет небольшой горизонтальный запас. Это два sequence-break/проходимость follow-up, которые нельзя считать решёнными до acceptance-бота.
**Softlock/неопределённость:** интегрированный прогон воспроизвёл падение при посадке с исходным 40 px перекрытием floor/lift. Перекрытие увеличено до 100 px, при этом сохранены 200 px `PIT` и обязательный вертикальный подъём. Доставка saw-тела, три сохранённых тела, посадка, подъём и lift→ledge прыжок подтверждены тремя запусками и стресс-сценарием. Обязательность frozen corpse всё ещё не доказана отрицательным тестом: возможный двухсмертный sequence break остаётся follow-up.
## Проверки, следующие из графов
1. Обобщить `AutomationSnapshot`: поимённые сигналы, состояния/координаты платформ, `supportIndex`, типы и флаги тел.
2. Для уровней 1–4 и 6–10 реализовать production-input acceptance-ботов; каждый intended route выполнить три раза подряд.
3. Отдельно проверить предполагаемые sequence break уровня 2 и необязательность `FREEZER` на уровне 10.
4. Проверить blocker-softlock уровня 8 после истечения `"clock"` справа от `PIT`.
5. Проверить узкие/динамические места: тело на плите уровней 3 и 9, прыжок lift→ledge уровней 4 и 10.
1. Сохранять полный integrated device-regression: intended route уровней 1–10 по три раза и отдельный recovery route уровня 8.
2. Отдельно проверить предполагаемый sequence break уровня 2 и необязательность `FREEZER` на уровне 10.
3. Добавить параметризованные проверки restart из ключевых промежуточных состояний.
4. Повторить bot matrix на дополнительных frame rates и поддерживаемых версиях Android.
5. Провести ручные сессии этапа 2 и скорректировать подсказки/допуски без ослабления механик.