Добавить сценарный фреймворк игрового бота
This commit is contained in:
@@ -1,15 +1,98 @@
|
|||||||
package com.projectsacrifice.game;
|
package com.projectsacrifice.game;
|
||||||
|
|
||||||
/**
|
import java.util.ArrayList;
|
||||||
* Black-box playtest bot for the fifth level. It operates through the same input
|
import java.util.Collections;
|
||||||
* flags used by touch controls and observes only player-visible session state.
|
import java.util.List;
|
||||||
*/
|
|
||||||
|
/** Runs black-box acceptance scenarios through the same input and state boundary as touch play. */
|
||||||
public final class LevelPlaytestBot {
|
public final class LevelPlaytestBot {
|
||||||
public interface Driver {
|
public interface Driver {
|
||||||
void startLevel(int oneBasedLevelNumber);
|
void startLevel(int oneBasedLevelNumber);
|
||||||
void setInput(boolean moveLeft, boolean moveRight, boolean jump);
|
void setInput(boolean moveLeft, boolean moveRight, boolean jump);
|
||||||
GameView.AutomationSnapshot snapshot();
|
GameView.AutomationSnapshot snapshot();
|
||||||
void waitForNextDecision(long millis);
|
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<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 {
|
public static final class Result {
|
||||||
@@ -18,13 +101,16 @@ public final class LevelPlaytestBot {
|
|||||||
private final int deaths;
|
private final int deaths;
|
||||||
private final int corpseCount;
|
private final int corpseCount;
|
||||||
private final String diagnostic;
|
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.completed = completed;
|
||||||
this.doorOpened = doorOpened;
|
this.doorOpened = doorOpened;
|
||||||
this.deaths = deaths;
|
this.deaths = deaths;
|
||||||
this.corpseCount = corpseCount;
|
this.corpseCount = corpseCount;
|
||||||
this.diagnostic = diagnostic;
|
this.diagnostic = diagnostic;
|
||||||
|
this.lastStep = lastStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean completed() { return completed; }
|
public boolean completed() { return completed; }
|
||||||
@@ -32,60 +118,85 @@ public final class LevelPlaytestBot {
|
|||||||
public int deaths() { return deaths; }
|
public int deaths() { return deaths; }
|
||||||
public int corpseCount() { return corpseCount; }
|
public int corpseCount() { return corpseCount; }
|
||||||
public String diagnostic() { return diagnostic; }
|
public String diagnostic() { return diagnostic; }
|
||||||
|
public String lastStep() { return lastStep; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result proveLevelFive(Driver driver, long timeoutMillis) {
|
public Result run(Driver driver, Scenario scenario, long timeoutMillis) {
|
||||||
driver.startLevel(5);
|
driver.startLevel(scenario.levelNumber);
|
||||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
long scenarioStartedAt = driver.currentTimeMillis();
|
||||||
|
long stepStartedAt = scenarioStartedAt;
|
||||||
|
int stepIndex = 0;
|
||||||
|
String lastStep = scenario.steps.isEmpty() ? null : scenario.steps.get(0).name;
|
||||||
boolean doorOpened = false;
|
boolean doorOpened = false;
|
||||||
int jumpStage = 0;
|
|
||||||
int respawnsAfterSacrifice = 0;
|
|
||||||
boolean wasAlive = true;
|
|
||||||
GameView.AutomationSnapshot last = driver.snapshot();
|
GameView.AutomationSnapshot last = driver.snapshot();
|
||||||
|
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (driver.currentTimeMillis() - scenarioStartedAt < timeoutMillis) {
|
||||||
last = driver.snapshot();
|
last = driver.snapshot();
|
||||||
doorOpened |= last.allDoorsOpen();
|
doorOpened |= last.allDoorsOpen();
|
||||||
if (last.completed()) {
|
if (last.completed()) {
|
||||||
driver.setInput(false, false, false);
|
driver.setInput(false, false, false);
|
||||||
return new Result(true, doorOpened, last.deaths(), last.corpseCount(),
|
return new Result(true, doorOpened, last.deaths(), last.corpseCount(),
|
||||||
"Level 5 completed by bot: deaths=" + last.deaths()
|
scenario.name + " completed; step=" + lastStep + "; " + snapshotState(last),
|
||||||
+ ", corpses=" + last.corpseCount() + ", doorOpened=" + doorOpened);
|
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(step.input.moveLeft, step.input.moveRight, step.input.jump);
|
||||||
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.waitForNextDecision(32L);
|
driver.waitForNextDecision(32L);
|
||||||
}
|
}
|
||||||
|
|
||||||
driver.setInput(false, false, false);
|
driver.setInput(false, false, false);
|
||||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||||
"Bot timed out on level 5: x=" + last.playerX() + ", y=" + last.playerY()
|
scenario.name + " did not complete; step=" + lastStep + "; " + snapshotState(last),
|
||||||
+ ", alive=" + last.playerAlive() + ", deaths=" + last.deaths()
|
lastStep);
|
||||||
+ ", corpses=" + last.corpseCount() + ", doorOpened=" + doorOpened
|
}
|
||||||
+ ", respawnsAfterSacrifice=" + respawnsAfterSacrifice);
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user