Добавить сценарный фреймворк игрового бота

This commit is contained in:
2026-08-18 01:05:08 +03:00
parent 20357c4c29
commit 77800d640a
2 changed files with 335 additions and 43 deletions
@@ -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;
}
}
}