From 650bfb3533bdec6dd27a7854fb6baa9a60ba3f26 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 17:13:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=BF=D1=80=D0=B8=20=D0=BC=D0=B5=D0=B4?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BD=D0=BE=D0=BC=20=D1=80=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=BD=D0=B3=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/EarlyLevelScenarios.java | 11 ++- .../game/LevelPlaytestBot.java | 59 ++++++++++++-- .../game/LevelPlaytestBotTest.java | 79 +++++++++++++++++++ 3 files changed, 138 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java index a3359ce..503f520 100644 --- a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java +++ b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java @@ -45,7 +45,9 @@ public final class EarlyLevelScenarios { 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(), + .step("Land on the far bridge body", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerOnGround() && snapshot.playerX() >= 800f) + .step("Jump from the bridge to safe floor", 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) @@ -65,7 +67,7 @@ public final class EarlyLevelScenarios { .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) + snapshot -> snapshot.playerX() >= 300f) .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(), @@ -76,7 +78,10 @@ public final class EarlyLevelScenarios { 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) + GameView.AutomationSnapshot::firstMovingPlatformPowered) + .step("Approach the powered moving lift", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.firstMovingPlatformPowered() + && 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(), diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java index e04a067..343fbd0 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java @@ -6,6 +6,9 @@ import java.util.List; /** Runs black-box acceptance scenarios through the same input and state boundary as touch play. */ public final class LevelPlaytestBot { + private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L; + private static final long WALL_WATCHDOG_MULTIPLIER = 10L; + public interface Driver { void startLevel(int oneBasedLevelNumber); void setInput(boolean moveLeft, boolean moveRight, boolean jump); @@ -123,22 +126,38 @@ public final class LevelPlaytestBot { public Result run(Driver driver, Scenario scenario, long timeoutMillis) { driver.startLevel(scenario.levelNumber); - long scenarioStartedAt = driver.currentTimeMillis(); - long stepStartedAt = scenarioStartedAt; + long scenarioStartedAtWall = driver.currentTimeMillis(); + long wallWatchdogMillis = wallWatchdogMillis(timeoutMillis); int stepIndex = 0; String lastStep = scenario.steps.isEmpty() ? null : scenario.steps.get(0).name; boolean doorOpened = false; GameView.AutomationSnapshot last = driver.snapshot(); + long scenarioStartedAtSimulation = last.elapsedMilliseconds(); + long stepStartedAtSimulation = scenarioStartedAtSimulation; - while (driver.currentTimeMillis() - scenarioStartedAt < timeoutMillis) { + while (true) { last = driver.snapshot(); + long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall); + long simulationElapsed = elapsed( + last.elapsedMilliseconds(), scenarioStartedAtSimulation); doorOpened |= last.allDoorsOpen(); if (last.completed()) { driver.setInput(false, false, false); return new Result(true, doorOpened, last.deaths(), last.corpseCount(), - scenario.name + " completed; step=" + lastStep + "; " + snapshotState(last), + scenario.name + " completed; step=" + lastStep + "; " + + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), lastStep); } + if (wallElapsed >= wallWatchdogMillis) { + driver.setInput(false, false, false); + return new Result(false, doorOpened, last.deaths(), last.corpseCount(), + scenario.name + " stopped by wall-clock watchdog; step=" + lastStep + + "; " + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), + lastStep); + } + if (simulationElapsed >= timeoutMillis) break; if (stepIndex >= scenario.steps.size()) break; Step step = scenario.steps.get(stepIndex); @@ -146,13 +165,16 @@ public final class LevelPlaytestBot { if (step.completion.reached(last)) { driver.setInput(false, false, false); stepIndex++; - stepStartedAt = driver.currentTimeMillis(); + stepStartedAtSimulation = last.elapsedMilliseconds(); continue; } - if (driver.currentTimeMillis() - stepStartedAt >= step.timeoutMillis) { + if (elapsed(last.elapsedMilliseconds(), stepStartedAtSimulation) + >= step.timeoutMillis) { driver.setInput(false, false, false); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), - scenario.name + " timed out; step=" + step.name + "; " + snapshotState(last), + scenario.name + " timed out in simulation; step=" + step.name + + "; " + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), step.name); } @@ -161,11 +183,32 @@ public final class LevelPlaytestBot { } driver.setInput(false, false, false); + long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall); + long simulationElapsed = elapsed( + last.elapsedMilliseconds(), scenarioStartedAtSimulation); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), - scenario.name + " did not complete; step=" + lastStep + "; " + snapshotState(last), + scenario.name + " exhausted its simulation budget; step=" + lastStep + + "; " + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), lastStep); } + 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); + } + + private static String timingState(long simulationElapsed, long wallElapsed) { + return "simulationElapsed=" + simulationElapsed + ", wallElapsed=" + wallElapsed; + } + private static String snapshotState(GameView.AutomationSnapshot snapshot) { return "x=" + snapshot.playerX() + ", y=" + snapshot.playerY() diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java index 88eaa36..b7fa858 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java @@ -68,6 +68,41 @@ public final class LevelPlaytestBotTest { assertTrue(result.diagnostic(), result.diagnostic().contains("allDoorsOpen=false")); } + @Test + public void slowRenderingUsesSimulationElapsedBudgetsInsteadOfHostTime() { + SlowRenderingDriver driver = new SlowRenderingDriver(true); + LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario + .builder(1, "Slow rendering") + .step("Reach simulated checkpoint", 200L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 500L); + + assertTrue(result.diagnostic(), result.completed()); + assertTrue("The host clock must be allowed to outrun simulated gameplay", + driver.wallMillis > 500L); + assertEquals(96L, driver.simulationMillis); + } + + @Test + public void frozenSimulationStopsAtWallClockWatchdogWithDiagnostic() { + SlowRenderingDriver driver = new SlowRenderingDriver(false); + LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario + .builder(1, "Frozen rendering") + .step("Wait for simulation", 1_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L); + + assertFalse(result.completed()); + assertEquals("Wait for simulation", result.lastStep()); + assertTrue(result.diagnostic(), result.diagnostic().contains("wall-clock watchdog")); + assertTrue(result.diagnostic(), result.diagnostic().contains("simulationElapsed=0")); + assertTrue(result.diagnostic(), result.diagnostic().contains("wallElapsed=120000")); + } + @Test public void levelFiveUsesTheGenericNamedScenario() { LevelFiveDriver driver = new LevelFiveDriver(); @@ -160,6 +195,50 @@ public final class LevelPlaytestBotTest { } } + private static final class SlowRenderingDriver implements LevelPlaytestBot.Driver { + private final boolean advancesSimulation; + private long wallMillis; + private long simulationMillis; + private boolean movingRight; + private boolean completed; + + SlowRenderingDriver(boolean advancesSimulation) { + this.advancesSimulation = advancesSimulation; + } + + @Override + public void startLevel(int oneBasedLevelNumber) { + wallMillis = 0L; + simulationMillis = 0L; + movingRight = false; + completed = false; + } + + @Override + public void setInput(boolean moveLeft, boolean moveRight, boolean jump) { + movingRight = moveRight; + } + + @Override + public GameView.AutomationSnapshot snapshot() { + return new GameView.AutomationSnapshot( + completed, true, false, false, movingRight, + 1, simulationMillis, 130f, 760f, 0, 0); + } + + @Override + public void waitForNextDecision(long millis) { + wallMillis += advancesSimulation ? 1_000L : 60_000L; + if (advancesSimulation) simulationMillis += millis; + if (movingRight && simulationMillis >= 96L) completed = true; + } + + @Override + public long currentTimeMillis() { + return wallMillis; + } + } + private static final class LevelFiveDriver implements LevelPlaytestBot.Driver { private int startedLevel; private long nowMillis;