Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad959dde0 | ||
|
|
cf845d5d9d | ||
|
|
a87e6cd62b | ||
|
|
de0f8b891f | ||
|
|
9e418df184 | ||
|
|
6790a84d86 | ||
|
|
70106e29d8 | ||
|
|
3ed6d9ebab | ||
|
|
a8f5ddc0a9 | ||
|
|
1ebad500f3 | ||
|
|
4c161d4172 |
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 и протокол ручных игровых сессий.
|
||||
|
||||
@@ -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 и скорректировать подсказки/допуски без ослабления механик.
|
||||
|
||||
Reference in New Issue
Block a user