diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java index 1cd7d1b..76e9bae 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java @@ -1,15 +1,98 @@ package com.projectsacrifice.game; -/** - * Black-box playtest bot for the fifth level. It operates through the same input - * flags used by touch controls and observes only player-visible session state. - */ +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 { 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 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 { @@ -18,13 +101,16 @@ public final class LevelPlaytestBot { private final int deaths; private final int corpseCount; private final String diagnostic; + private final String lastStep; - Result(boolean completed, boolean doorOpened, int deaths, int corpseCount, String diagnostic) { + Result(boolean completed, boolean doorOpened, int deaths, int corpseCount, + String diagnostic, String lastStep) { this.completed = completed; this.doorOpened = doorOpened; this.deaths = deaths; this.corpseCount = corpseCount; this.diagnostic = diagnostic; + this.lastStep = lastStep; } public boolean completed() { return completed; } @@ -32,60 +118,85 @@ public final class LevelPlaytestBot { public int deaths() { return deaths; } public int corpseCount() { return corpseCount; } public String diagnostic() { return diagnostic; } + public String lastStep() { return lastStep; } } - public Result proveLevelFive(Driver driver, long timeoutMillis) { - driver.startLevel(5); - long deadline = System.currentTimeMillis() + timeoutMillis; + public Result run(Driver driver, Scenario scenario, long timeoutMillis) { + driver.startLevel(scenario.levelNumber); + long scenarioStartedAt = driver.currentTimeMillis(); + long stepStartedAt = scenarioStartedAt; + int stepIndex = 0; + String lastStep = scenario.steps.isEmpty() ? null : scenario.steps.get(0).name; boolean doorOpened = false; - int jumpStage = 0; - int respawnsAfterSacrifice = 0; - boolean wasAlive = true; GameView.AutomationSnapshot last = driver.snapshot(); - while (System.currentTimeMillis() < deadline) { + while (driver.currentTimeMillis() - scenarioStartedAt < timeoutMillis) { last = driver.snapshot(); doorOpened |= last.allDoorsOpen(); if (last.completed()) { driver.setInput(false, false, false); return new Result(true, doorOpened, last.deaths(), last.corpseCount(), - "Level 5 completed by bot: deaths=" + last.deaths() - + ", corpses=" + last.corpseCount() + ", doorOpened=" + doorOpened); + scenario.name + " completed; step=" + lastStep + "; " + snapshotState(last), + lastStep); + } + if (stepIndex >= scenario.steps.size()) break; + + Step step = scenario.steps.get(stepIndex); + lastStep = step.name; + if (step.completion.reached(last)) { + stepIndex++; + stepStartedAt = driver.currentTimeMillis(); + continue; + } + if (driver.currentTimeMillis() - stepStartedAt >= 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), + step.name); } - if (!last.playerAlive()) { - driver.setInput(false, false, false); - wasAlive = false; - } else if (last.deaths() == 0) { - driver.setInput(false, true, false); - } else if (!doorOpened) { - driver.setInput(false, false, false); - } else { - if (!wasAlive) { - respawnsAfterSacrifice++; - jumpStage = 0; - } - if (jumpStage == 0 && last.playerX() < 390f) { - driver.setInput(false, true, false); - } else if (jumpStage == 0) { - driver.setInput(false, true, true); - jumpStage = 1; - } else if (jumpStage == 1 && last.playerX() >= 750f) { - driver.setInput(false, true, true); - jumpStage = 2; - } else { - driver.setInput(false, true, false); - } - wasAlive = true; - } + driver.setInput(step.input.moveLeft, step.input.moveRight, step.input.jump); driver.waitForNextDecision(32L); } driver.setInput(false, false, false); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), - "Bot timed out on level 5: x=" + last.playerX() + ", y=" + last.playerY() - + ", alive=" + last.playerAlive() + ", deaths=" + last.deaths() - + ", corpses=" + last.corpseCount() + ", doorOpened=" + doorOpened - + ", respawnsAfterSacrifice=" + respawnsAfterSacrifice); + scenario.name + " did not complete; step=" + lastStep + "; " + snapshotState(last), + lastStep); + } + + 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() + + ", 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("Jump over the saw", 2_000L, Input.rightJump(), + snapshot -> snapshot.playerX() >= 430f) + .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(); } } diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java new file mode 100644 index 0000000..52658a2 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java @@ -0,0 +1,181 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public final class LevelPlaytestBotTest { + @Test + public void scenarioProgressesThroughNamedSteps() { + ScriptedDriver driver = new ScriptedDriver(); + LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario + .builder(7, "Reusable 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(); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L); + + assertTrue(result.diagnostic(), result.completed()); + assertEquals(7, driver.startedLevel); + assertEquals("Reach the goal", result.lastStep()); + } + + @Test + public void stepTimeoutReportsItsNameAndObservableSnapshot() { + ScriptedDriver driver = new ScriptedDriver(false); + LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario + .builder(5, "Diagnostic scenario") + .step("Keep the plate pressed", 64L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::allDoorsOpen) + .build(); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L); + + assertFalse(result.completed()); + assertEquals("Keep the plate pressed", result.lastStep()); + assertTrue(result.diagnostic(), result.diagnostic().contains("step=Keep the plate pressed")); + assertTrue(result.diagnostic(), result.diagnostic().contains("x=100.0")); + assertTrue(result.diagnostic(), result.diagnostic().contains("y=760.0")); + assertTrue(result.diagnostic(), result.diagnostic().contains("alive=true")); + assertTrue(result.diagnostic(), result.diagnostic().contains("deaths=0")); + assertTrue(result.diagnostic(), result.diagnostic().contains("corpses=0")); + assertTrue(result.diagnostic(), result.diagnostic().contains("allDoorsOpen=false")); + } + + @Test + public void levelFiveUsesTheGenericNamedScenario() { + LevelFiveDriver driver = new LevelFiveDriver(); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().proveLevelFive(driver, 25_000L); + + assertTrue(result.diagnostic(), result.completed()); + assertEquals(5, driver.startedLevel); + assertEquals("Reach the relic", result.lastStep()); + assertEquals(1, result.deaths()); + assertEquals(1, result.corpseCount()); + assertTrue(result.doorOpened()); + } + + private static final class ScriptedDriver implements LevelPlaytestBot.Driver { + private int startedLevel; + private long nowMillis; + private boolean moveLeft; + private boolean moveRight; + private int deaths; + private boolean completed; + private final boolean progresses; + + ScriptedDriver() { + this(true); + } + + ScriptedDriver(boolean progresses) { + this.progresses = progresses; + } + + @Override + public void startLevel(int oneBasedLevelNumber) { + startedLevel = oneBasedLevelNumber; + } + + @Override + public void setInput(boolean moveLeft, boolean moveRight, boolean jump) { + this.moveLeft = moveLeft; + this.moveRight = moveRight; + } + + @Override + public GameView.AutomationSnapshot snapshot() { + return new GameView.AutomationSnapshot( + completed, + true, + false, + 100f, + 760f, + deaths, + deaths + ); + } + + @Override + public void waitForNextDecision(long millis) { + nowMillis += millis; + if (progresses && moveRight && deaths == 0) { + deaths = 1; + } else if (progresses && moveLeft && deaths == 1) { + completed = true; + } + } + + @Override + public long currentTimeMillis() { + return nowMillis; + } + } + + private static final class LevelFiveDriver implements LevelPlaytestBot.Driver { + private int startedLevel; + private long nowMillis; + private boolean moveRight; + private boolean jump; + private boolean alive = true; + private boolean doorOpen; + private boolean completed; + private int deaths; + private int corpses; + private float x = 130f; + + @Override + public void startLevel(int oneBasedLevelNumber) { + startedLevel = oneBasedLevelNumber; + } + + @Override + public void setInput(boolean moveLeft, boolean moveRight, boolean jump) { + this.moveRight = moveRight; + this.jump = jump; + } + + @Override + public GameView.AutomationSnapshot snapshot() { + return new GameView.AutomationSnapshot( + completed, alive, doorOpen, x, 760f, deaths, corpses); + } + + @Override + public void waitForNextDecision(long millis) { + nowMillis += millis; + if (!alive) { + alive = true; + x = 130f; + } else if (deaths == 0 && moveRight && !jump) { + deaths = 1; + corpses = 1; + alive = false; + doorOpen = true; + x = 570f; + } else if (moveRight && jump) { + x += 60f; + } else if (moveRight && x < 390f) { + x = 400f; + } else if (moveRight && x < 750f) { + x = 760f; + } else if (moveRight && x < 1_390f) { + x = 1_500f; + } else if (moveRight) { + x = 2_080f; + completed = true; + } + } + + @Override + public long currentTimeMillis() { + return nowMillis; + } + } +}