diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightPlaythroughTest.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightPlaythroughTest.java new file mode 100644 index 0000000..cc40fbc --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightPlaythroughTest.java @@ -0,0 +1,90 @@ +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 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 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()); + } + } + } + + private static AndroidDriver driver(ActivityScenario activity) { + final GameView[] view = new GameView[1]; + activity.onActivity(mainActivity -> view[0] = mainActivity.gameViewForAutomation()); + return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]); + } + + 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("Level-8 bot interrupted", interrupted); + } + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java new file mode 100644 index 0000000..43c628b --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java @@ -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(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java b/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java index a3eca67..31781bb 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java @@ -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), 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)), + 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")) ); } diff --git a/docs/LEVEL_SOLUTION_GRAPHS.md b/docs/LEVEL_SOLUTION_GRAPHS.md index fdbbb82..383954e 100644 --- a/docs/LEVEL_SOLUTION_GRAPHS.md +++ b/docs/LEVEL_SOLUTION_GRAPHS.md @@ -197,15 +197,15 @@ Door закрывает путь к goal ## Уровень 8 — `Borrowed Time` -**Статус:** intended route и recovery route доказаны ботом, каждый по 3/3 последовательных прохождения без смертей. +**Статус:** 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=(900,880,610,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 → прыгнуть на moving platform и пересечь PIT @@ -213,13 +213,20 @@ PIT разрывает floor, а Door закрывает дальнейший п → пройти 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` дают запас на проход двери, не меняя crossing или recovery. -**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. -**Softlock/неопределённость:** исходный blocker-softlock был воспроизведён после истечения `"clock"` справа от `PIT`. Добавлен высокий recovery relay того же сигнала, недоступный при обычном беге по floor; intended route и восстановление после истечения таймера отдельно подтверждены по три раза. +**Softlock/неопределённость:** прежний blocker устранён: после истечения `"clock"` справа от PIT высокий relay гарантирует restart-free повторное открытие двери. Relay не помогает слева от PIT и не отменяет timed-platform crossing. Оба пути проверены без прямой мутации состояния, teleport, auto-complete или restart. ## Уровень 9 — `Two Good Reasons`