package com.projectsacrifice.game; import java.util.ArrayList; import java.util.Collections; 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 DECISION_MILLIS = 16L; 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); GameView.AutomationSnapshot snapshot(); void waitForNextDecision(long millis); default long currentTimeMillis() { return System.currentTimeMillis(); } } public static final class Input { private static final Input NONE = new Input(false, false, false); private static final Input LEFT = new Input(true, false, false); private static final Input RIGHT = new Input(false, true, false); private static final Input RIGHT_JUMP = new Input(false, true, true); private final boolean moveLeft; private final boolean moveRight; private final boolean jump; private Input(boolean moveLeft, boolean moveRight, boolean jump) { this.moveLeft = moveLeft; this.moveRight = moveRight; this.jump = jump; } public static Input none() { return NONE; } public static Input left() { return LEFT; } public static Input right() { return RIGHT; } public static Input rightJump() { return RIGHT_JUMP; } } @FunctionalInterface public interface SnapshotCondition { boolean reached(GameView.AutomationSnapshot snapshot); } public static SnapshotCondition groundedAliveAtOrBeyond(float minimumX) { return snapshot -> snapshot.playerAlive() && snapshot.playerOnGround() && snapshot.playerX() >= minimumX; } public static SnapshotCondition whileDeathsAtMost( int maximumDeaths, SnapshotCondition completion) { return snapshot -> { if (snapshot.deaths() > maximumDeaths) { throw new AssertionError("Route expected at most " + maximumDeaths + " deaths but observed " + snapshot.deaths() + "; x=" + snapshot.playerX() + ", y=" + snapshot.playerY() + ", alive=" + snapshot.playerAlive() + ", grounded=" + snapshot.playerOnGround()); } return completion.reached(snapshot); }; } public static final class Scenario { private final int levelNumber; private final String name; private final List steps; private Scenario(int levelNumber, String name, List steps) { this.levelNumber = levelNumber; this.name = name; this.steps = Collections.unmodifiableList(new ArrayList<>(steps)); } public static Builder builder(int levelNumber, String name) { return new Builder(levelNumber, name); } public static final class Builder { private final int levelNumber; private final String name; private final List steps = new ArrayList<>(); private Builder(int levelNumber, String name) { this.levelNumber = levelNumber; this.name = name; } public Builder step(String name, long timeoutMillis, Input input, SnapshotCondition completion) { steps.add(new Step(name, timeoutMillis, input, completion)); return this; } public Scenario build() { return new Scenario(levelNumber, name, steps); } } } private static final class Step { final String name; final long timeoutMillis; final Input input; final SnapshotCondition completion; Step(String name, long timeoutMillis, Input input, SnapshotCondition completion) { this.name = name; this.timeoutMillis = timeoutMillis; this.input = input; this.completion = completion; } } public static final class Result { private final boolean completed; private final boolean doorOpened; private final int deaths; private final int corpseCount; private final String diagnostic; private final String lastStep; private final String traceJson; private final String traceId; Result(boolean completed, boolean doorOpened, int deaths, int corpseCount, String diagnostic, String lastStep, BotCommandTrace trace) { this.completed = completed; this.doorOpened = doorOpened; this.deaths = deaths; this.corpseCount = corpseCount; this.traceJson = trace.json(); this.traceId = trace.id(); this.diagnostic = diagnostic + "; traceId=" + traceId; this.lastStep = lastStep; } public boolean completed() { return completed; } public boolean doorOpened() { return doorOpened; } public int deaths() { return deaths; } public int corpseCount() { return corpseCount; } public String diagnostic() { return diagnostic; } public String lastStep() { return lastStep; } public String traceJson() { return traceJson; } public String traceId() { return traceId; } } public Result run(Driver driver, Scenario scenario, long timeoutMillis) { driver.startLevel(scenario.levelNumber); 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(); BotCommandTrace.Builder trace = BotCommandTrace.builder( scenario.levelNumber, scenario.name, last); long scenarioStartedAtSimulation = last.elapsedMilliseconds(); long stepStartedAtSimulation = scenarioStartedAtSimulation; while (true) { last = driver.snapshot(); long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall); long simulationElapsed = elapsed( last.elapsedMilliseconds(), scenarioStartedAtSimulation); doorOpened |= last.allDoorsOpen(); if (last.completed()) { applyInput(driver, trace, Input.none(), lastStep, last); BotCommandTrace finishedTrace = trace.finish("COMPLETED", lastStep, last); return new Result(true, doorOpened, last.deaths(), last.corpseCount(), scenario.name + " completed; step=" + lastStep + "; " + timingState(simulationElapsed, wallElapsed) + "; " + snapshotState(last), lastStep, finishedTrace); } if (wallElapsed >= wallWatchdogMillis) { applyInput(driver, trace, Input.none(), lastStep, last); BotCommandTrace finishedTrace = trace.finish("WALL_WATCHDOG", lastStep, last); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), scenario.name + " stopped by wall-clock watchdog; step=" + lastStep + "; " + timingState(simulationElapsed, wallElapsed) + "; " + snapshotState(last), lastStep, finishedTrace); } if (simulationElapsed >= timeoutMillis) break; if (stepIndex >= scenario.steps.size()) break; Step step = scenario.steps.get(stepIndex); lastStep = step.name; if (step.completion.reached(last)) { trace.checkpoint(step.name, last); applyInput(driver, trace, Input.none(), step.name, last); stepIndex++; stepStartedAtSimulation = last.elapsedMilliseconds(); continue; } if (elapsed(last.elapsedMilliseconds(), stepStartedAtSimulation) >= step.timeoutMillis) { applyInput(driver, trace, Input.none(), step.name, last); BotCommandTrace finishedTrace = trace.finish("STEP_TIMEOUT", step.name, last); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), scenario.name + " timed out in simulation; step=" + step.name + "; " + timingState(simulationElapsed, wallElapsed) + "; " + snapshotState(last), step.name, finishedTrace); } applyInput(driver, trace, step.input, step.name, last); driver.waitForNextDecision(DECISION_MILLIS); } applyInput(driver, trace, Input.none(), lastStep, last); long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall); long simulationElapsed = elapsed( last.elapsedMilliseconds(), scenarioStartedAtSimulation); BotCommandTrace finishedTrace = trace.finish("SIMULATION_BUDGET", lastStep, last); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), scenario.name + " exhausted its simulation budget; step=" + lastStep + "; " + timingState(simulationElapsed, wallElapsed) + "; " + snapshotState(last), lastStep, finishedTrace); } private static void applyInput(Driver driver, BotCommandTrace.Builder trace, Input input, String step, GameView.AutomationSnapshot snapshot) { driver.setInput(input.moveLeft, input.moveRight, input.jump); trace.input(step, input.moveLeft, input.moveRight, input.jump, snapshot); } 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() + ", alive=" + snapshot.playerAlive() + ", completed=" + snapshot.completed() + ", deaths=" + snapshot.deaths() + ", corpses=" + snapshot.corpseCount() + ", firstCorpseX=" + snapshot.firstCorpseX() + ", firstHazardX=" + snapshot.firstHazardX() + ", allDoorsOpen=" + snapshot.allDoorsOpen(); } public Result proveLevelFive(Driver driver, long timeoutMillis) { return run(driver, levelFiveScenario(), timeoutMillis); } private static Scenario levelFiveScenario() { return Scenario.builder(5, "Level 5") .step("Sacrifice at the saw", 4_000L, Input.right(), snapshot -> snapshot.deaths() >= 1) .step("Wait for the pressure-plate door", 5_000L, Input.none(), GameView.AutomationSnapshot::allDoorsOpen) .step("Wait for respawn", 3_000L, Input.none(), snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Approach the saw", 4_000L, Input.right(), snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 390f) .step("Take off before the saw", 1_000L, Input.rightJump(), snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f) .step("Clear the saw", 2_000L, Input.right(), snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) .step("Run to the ledge", 4_000L, Input.right(), snapshot -> snapshot.playerX() >= 750f) .step("Jump onto the ledge", 2_000L, Input.rightJump(), snapshot -> snapshot.playerX() >= 790f) .step("Reach the relic", 8_000L, Input.right(), GameView.AutomationSnapshot::completed) .build(); } }