diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java index 905c8a7..2c5ae00 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java @@ -3,6 +3,9 @@ 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; + private static final float LAUNCH_PLATFORM_X = 1_150f; + + private String lastStep = "Start level 8"; static final class Result { private final boolean completed; @@ -21,145 +24,161 @@ final class LevelEightScenario { } 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); + GameView.AutomationSnapshot snapshot = driver.snapshot(); + PlaytestSimulationBudget budget = PlaytestSimulationBudget.start( + snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis); + snapshot = crossPit(driver, budget); + if (failed(snapshot, budget, driver)) { + return failure("Cross the pit before the clock expires", snapshot, budget, driver); } - snapshot = await(driver, deadline, "Wait for the far-side trap", + snapshot = await(driver, budget, "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); + if (failed(snapshot, budget, driver)) { + return failure("Wait for the far-side trap", snapshot, budget, driver); } - snapshot = await(driver, deadline, "Return beneath the recovery relay", + snapshot = await(driver, budget, "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); + if (failed(snapshot, budget, driver)) { + return failure("Return beneath the recovery relay", snapshot, budget, driver); } - snapshot = pulseLeftJumpThenAwait(driver, deadline, "Jump into the recovery relay", + snapshot = pulseLeftJumpThenAwait(driver, budget, "Jump into the recovery relay", current -> current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) { - return failure("Jump into the recovery relay", snapshot); + if (failed(snapshot, budget, driver)) { + return failure("Jump into the recovery relay", snapshot, budget, driver); } - snapshot = await(driver, deadline, "Land after rearming the clock", + snapshot = await(driver, budget, "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); + if (failed(snapshot, budget, driver)) { + return failure("Land after rearming the clock", snapshot, budget, driver); } - return finishFromFarSide(driver, deadline, snapshot, "Finish after recovery"); + return finishFromFarSide(driver, budget, 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); + GameView.AutomationSnapshot snapshot = driver.snapshot(); + PlaytestSimulationBudget budget = PlaytestSimulationBudget.start( + snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis); + snapshot = crossPit(driver, budget); + if (failed(snapshot, budget, driver)) { + return failure("Cross the pit before the clock expires", snapshot, budget, driver); } - return finishFromFarSide(driver, deadline, snapshot, "Finish on the original timer"); + return finishFromFarSide(driver, budget, 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", + private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver, + PlaytestSimulationBudget budget) { + GameView.AutomationSnapshot snapshot = await(driver, budget, "Trigger the left timer", LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::allDoorsOpen); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; long timerStartedAt = snapshot.elapsedMilliseconds(); - snapshot = await(driver, deadline, "Approach the pit", + snapshot = await(driver, budget, "Approach the pit", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; - snapshot = await(driver, deadline, "Wait for the moving platform", + snapshot = await(driver, budget, "Wait for the moving platform", LevelPlaytestBot.Input.none(), current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L && current.firstMovingPlatformX() <= 1_050f); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, 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", + snapshot = await(driver, budget, "Land on the moving platform", LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::playerOnMovingPlatform); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; + + snapshot = await(driver, budget, "Ride the moving platform to the launch zone", + LevelPlaytestBot.Input.none(), + current -> MovingPlatformLaunchPolicy.ready(current, LAUNCH_PLATFORM_X)); + if (failed(snapshot, budget, 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", + snapshot = await(driver, budget, "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", + if (failed(snapshot, budget, driver)) return snapshot; + snapshot = awaitFarFloorLandingOnOriginalTimer(driver, budget); + if (failed(snapshot, budget, driver)) return snapshot; + return await(driver, budget, "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 + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget ) { - GameView.AutomationSnapshot snapshot = await(driver, deadline, + GameView.AutomationSnapshot snapshot = await(driver, budget, "Land on the far floor", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f && current.playerY() >= 779f && current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) return snapshot; - return await(driver, deadline, "Stabilize on the far floor", + if (failed(snapshot, budget, driver)) return snapshot; + return await(driver, budget, "Stabilize on the far floor", LevelPlaytestBot.Input.left(), current -> current.playerX() < 1_580f && current.playerY() >= 779f && current.allDoorsOpen()); } - private Result finishFromFarSide(LevelPlaytestBot.Driver driver, long deadline, + private Result finishFromFarSide(LevelPlaytestBot.Driver driver, + PlaytestSimulationBudget budget, GameView.AutomationSnapshot snapshot, String step) { - snapshot = await(driver, deadline, "Reach the open-door jump", + snapshot = await(driver, budget, "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); + if (failed(snapshot, budget, driver)) { + return failure("Reach the open-door jump", snapshot, budget, driver); } driver.setInput(false, true, true); driver.waitForNextDecision(64L); driver.setInput(false, true, false); - snapshot = await(driver, deadline, step, LevelPlaytestBot.Input.right(), + snapshot = await(driver, budget, 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)); + if (!snapshot.completed()) return failure(step, snapshot, budget, driver); + return new Result(true, snapshot.deaths(), step + " completed; " + + budget.timingState(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + + "; " + state(snapshot)); } private GameView.AutomationSnapshot pulseLeftJumpThenAwait( - LevelPlaytestBot.Driver driver, long deadline, String step, + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, 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); + return await(driver, budget, step, LevelPlaytestBot.Input.left(), completion); } private GameView.AutomationSnapshot await( - LevelPlaytestBot.Driver driver, long deadline, String step, + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step, LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion ) { + lastStep = step; GameView.AutomationSnapshot snapshot = driver.snapshot(); - while (driver.currentTimeMillis() < deadline && snapshot.playerAlive() + while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + && snapshot.playerAlive() && !snapshot.completed() && !completion.reached(snapshot)) { setInput(driver, input); driver.waitForNextDecision(DECISION_MILLIS); snapshot = driver.snapshot(); } - if (driver.currentTimeMillis() >= deadline || !snapshot.playerAlive()) { + if (budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + || !snapshot.playerAlive()) { driver.setInput(false, false, false); } return snapshot; @@ -177,14 +196,20 @@ final class LevelEightScenario { } } - private static boolean failed(GameView.AutomationSnapshot snapshot, long deadline, + private static boolean failed(GameView.AutomationSnapshot snapshot, + PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) { return !snapshot.completed() - && (!snapshot.playerAlive() || driver.currentTimeMillis() >= deadline); + && (!snapshot.playerAlive() + || budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())); } - private static Result failure(String step, GameView.AutomationSnapshot snapshot) { - return new Result(false, snapshot.deaths(), step + " failed; " + state(snapshot)); + private Result failure(String step, GameView.AutomationSnapshot snapshot, + PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) { + return new Result(false, snapshot.deaths(), step + " failed; lastStep=" + lastStep + + "; " + budget.timingState( + snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + + "; " + state(snapshot)); } private static String state(GameView.AutomationSnapshot snapshot) { @@ -194,6 +219,11 @@ final class LevelEightScenario { + ", doorOpen=" + snapshot.allDoorsOpen() + ", completed=" + snapshot.completed() + ", elapsedMs=" + snapshot.elapsedMilliseconds() - + ", deaths=" + snapshot.deaths(); + + ", deaths=" + snapshot.deaths() + + ", platformX=" + snapshot.firstMovingPlatformX() + + ", platformY=" + snapshot.firstMovingPlatformY() + + ", onMovingPlatform=" + snapshot.playerOnMovingPlatform() + + ", onGround=" + snapshot.playerOnGround() + + ", platformPowered=" + snapshot.firstMovingPlatformPowered(); } } diff --git a/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java b/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java new file mode 100644 index 0000000..2049df4 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java @@ -0,0 +1,13 @@ +package com.projectsacrifice.game; + +/** Stable public-state gate for a single moving-platform launch. */ +final class MovingPlatformLaunchPolicy { + private MovingPlatformLaunchPolicy() {} + + static boolean ready(GameView.AutomationSnapshot snapshot, float minimumPlatformX) { + return snapshot.playerOnMovingPlatform() + && snapshot.playerOnGround() + && snapshot.firstMovingPlatformPowered() + && snapshot.firstMovingPlatformX() >= minimumPlatformX; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java b/app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java new file mode 100644 index 0000000..e392692 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java @@ -0,0 +1,56 @@ +package com.projectsacrifice.game; + +/** Simulation-first timeout with a separate watchdog for a stalled device. */ +final class PlaytestSimulationBudget { + private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L; + private static final long WALL_WATCHDOG_MULTIPLIER = 10L; + + private final long simulationStartedAt; + private final long wallStartedAt; + private final long simulationBudgetMillis; + private final long wallWatchdogMillis; + + private PlaytestSimulationBudget(long simulationStartedAt, long wallStartedAt, + long simulationBudgetMillis) { + this.simulationStartedAt = simulationStartedAt; + this.wallStartedAt = wallStartedAt; + this.simulationBudgetMillis = simulationBudgetMillis; + this.wallWatchdogMillis = wallWatchdogMillis(simulationBudgetMillis); + } + + static PlaytestSimulationBudget start(long simulationStartedAt, long wallStartedAt, + long simulationBudgetMillis) { + return new PlaytestSimulationBudget( + simulationStartedAt, wallStartedAt, simulationBudgetMillis); + } + + boolean exhausted(long simulationNow, long wallNow) { + return simulationElapsed(simulationNow) >= simulationBudgetMillis + || wallElapsed(wallNow) >= wallWatchdogMillis; + } + + String timingState(long simulationNow, long wallNow) { + return "simulationElapsed=" + simulationElapsed(simulationNow) + + ", wallElapsed=" + wallElapsed(wallNow); + } + + private long simulationElapsed(long simulationNow) { + return elapsed(simulationNow, simulationStartedAt); + } + + private long wallElapsed(long wallNow) { + return elapsed(wallNow, wallStartedAt); + } + + private static long wallWatchdogMillis(long simulationBudgetMillis) { + if (simulationBudgetMillis > Long.MAX_VALUE / WALL_WATCHDOG_MULTIPLIER) { + return Long.MAX_VALUE; + } + return Math.max(MIN_WALL_WATCHDOG_MILLIS, + simulationBudgetMillis * WALL_WATCHDOG_MULTIPLIER); + } + + private static long elapsed(long current, long startedAt) { + return Math.max(0L, current - startedAt); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java b/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java new file mode 100644 index 0000000..b56184b --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java @@ -0,0 +1,27 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class MovingPlatformLaunchPolicyTest { + @Test + public void launchRequiresStablePoweredSupportInTheLaunchZone() { + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(910f, true, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, false, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, false, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, false), 1_150f)); + assertTrue(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, true), 1_150f)); + } + + private static GameView.AutomationSnapshot snapshot( + float platformX, boolean supported, boolean grounded, boolean powered + ) { + return new GameView.AutomationSnapshot( + false, true, true, false, false, 8, + 4_000L, 1_100f, 670f, 0, 0, + platformX, 770f, supported, powered, Float.NaN, grounded + ); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java b/app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java new file mode 100644 index 0000000..977ba8f --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java @@ -0,0 +1,21 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class PlaytestSimulationBudgetTest { + @Test + public void slowRenderingUsesSimulationBudgetWithASeparateWallWatchdog() { + PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(0L, 0L, 500L); + + assertFalse("Host time may outrun active simulation", budget.exhausted(96L, 1_000L)); + assertTrue("Simulation budget remains authoritative", budget.exhausted(500L, 5_000L)); + + PlaytestSimulationBudget frozen = PlaytestSimulationBudget.start(0L, 0L, 5_000L); + assertFalse(frozen.exhausted(0L, 119_999L)); + assertTrue("Frozen rendering stops at the same watchdog as the generic bot", + frozen.exhausted(0L, 120_000L)); + } +}