292 lines
13 KiB
Java
292 lines
13 KiB
Java
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<Step> steps;
|
|
|
|
private Scenario(int levelNumber, String name, List<Step> 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<Step> 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();
|
|
}
|
|
}
|