Завершить интеграционную устойчивость ботов этапа 3

This commit is contained in:
2026-08-18 18:27:33 +03:00
parent a8d72024f2
commit 9d792bdee4
8 changed files with 156 additions and 44 deletions
@@ -2,7 +2,6 @@ package com.projectsacrifice.game;
import android.app.Instrumentation;
import androidx.lifecycle.Lifecycle;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
@@ -21,12 +20,16 @@ public final class GameLifecycleTest {
public void returningFromBackgroundKeepsPuzzleVisiblePausedAndClearsHeldInput() {
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
final MainActivity[] activity = new MainActivity[1];
final GameView[] view = new GameView[1];
scenario.onActivity(activity -> view[0] = activity.gameViewForAutomation());
scenario.onActivity(current -> {
activity[0] = current;
view[0] = current.gameViewForAutomation();
});
createRetainedCorpse(instrumentation, view[0]);
runOnGame(instrumentation, view[0], false, false, true, false);
scenario.moveToState(Lifecycle.State.STARTED);
instrumentation.runOnMainSync(activity[0]::onPause);
GameView.AutomationSnapshot backgroundStart = snapshot(instrumentation, view[0]);
sleep(700L);
GameView.AutomationSnapshot backgroundEnd = snapshot(instrumentation, view[0]);
@@ -37,7 +40,7 @@ public final class GameLifecycleTest {
assertEquals(backgroundStart.playerX(), backgroundEnd.playerX(), 0.01f);
assertEquals(backgroundStart.elapsedMilliseconds(), backgroundEnd.elapsedMilliseconds());
scenario.moveToState(Lifecycle.State.RESUMED);
instrumentation.runOnMainSync(activity[0]::onResume);
GameView.AutomationSnapshot returned = snapshot(instrumentation, view[0]);
assertTrue("Returning to an active puzzle must show the pause screen", returned.visiblyPaused());
assertFalse("Held touch input must not survive backgrounding", returned.inputActive());
@@ -20,10 +20,13 @@ final class LateLevelScenarios {
snapshot -> snapshot.playerX() >= 626f
&& snapshot.playerY() >= 668f
&& snapshot.playerY() <= 690f)
.step("Jump from the block to the first ledge", 2_500L,
.step("Land on the first ledge from the frozen block", 3_000L,
LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 750f
&& snapshot.playerY() <= 505f)
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerX() >= 810f
&& snapshot.playerY() <= 500f))
.step("Cross the first ledge", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerX() >= 1_230f)
.step("Jump the gap to the second ledge", 2_500L,
@@ -117,19 +120,24 @@ final class LateLevelScenarios {
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1)
.step("Observe the delivered body power the lift", 3_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1
&& snapshot.firstMovingPlatformPowered())
.step("Wait for the freezer volunteer", 3_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the launch saw", 2_500L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(280f)))
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
.step("Take off before the launch saw", 1_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 680f))
&& snapshot.playerY() <= 580f))
.step("Clear the launch saw", 2_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
@@ -150,13 +158,13 @@ final class LateLevelScenarios {
.step("Return to the launch saw", 2_500L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(2,
LevelPlaytestBot.groundedAliveAtOrBeyond(280f)))
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
.step("Take off before the saw again", 1_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(2,
snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 680f))
&& snapshot.playerY() <= 580f))
.step("Clear the saw again", 2_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(2,
@@ -203,13 +211,13 @@ final class LateLevelScenarios {
.step("Approach the saw for the final crossing", 2_500L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(3,
LevelPlaytestBot.groundedAliveAtOrBeyond(280f)))
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
.step("Take off for the final saw crossing", 1_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(3,
snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 680f))
&& snapshot.playerY() <= 580f))
.step("Clear the saw for the final crossing", 2_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(3,
@@ -33,6 +33,14 @@ final class LevelEightScenario {
return failure("Cross the pit before the clock expires", snapshot, budget, driver);
}
snapshot = await(driver, budget, "Move clear of the platform sweep",
LevelPlaytestBot.Input.right(),
current -> current.playerX() >= 1_680f
&& current.playerY() >= 779f && current.allDoorsOpen());
if (failed(snapshot, budget, driver)) {
return failure("Move clear of the platform sweep", snapshot, budget, driver);
}
snapshot = await(driver, budget, "Wait for the far-side trap",
LevelPlaytestBot.Input.none(),
current -> current.playerX() >= 1_510f && !current.allDoorsOpen());
@@ -40,21 +48,21 @@ final class LevelEightScenario {
return failure("Wait for the far-side trap", snapshot, budget, driver);
}
snapshot = await(driver, budget, "Return beneath the recovery relay",
LevelPlaytestBot.Input.left(),
current -> current.playerX() <= 1_680f && current.playerY() >= 779f);
snapshot = positionBeneathRecoveryRelay(driver, budget);
if (failed(snapshot, budget, driver)) {
return failure("Return beneath the recovery relay", snapshot, budget, driver);
}
snapshot = pulseLeftJumpThenAwait(driver, budget, "Jump into the recovery relay",
snapshot = pulseRightJumpThenAwait(driver, budget, "Jump into the recovery relay",
current -> current.allDoorsOpen());
if (failed(snapshot, budget, driver)) {
return failure("Jump into the recovery relay", snapshot, budget, driver);
}
snapshot = await(driver, budget, "Land after rearming the clock",
LevelPlaytestBot.Input.none(), current -> current.playerY() >= 779f);
LevelPlaytestBot.Input.right(),
current -> current.playerX() >= 1_650f
&& current.playerY() >= 779f && current.allDoorsOpen());
if (failed(snapshot, budget, driver)) {
return failure("Land after rearming the clock", snapshot, budget, driver);
}
@@ -85,10 +93,10 @@ final class LevelEightScenario {
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f);
if (failed(snapshot, budget, driver)) return snapshot;
snapshot = await(driver, budget, "Wait for the moving platform",
snapshot = await(driver, budget, "Wait for the outbound moving platform",
LevelPlaytestBot.Input.none(),
current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L
&& current.firstMovingPlatformX() <= 1_050f);
&& MovingPlatformLaunchPolicy.readyToBoard(current, 1_050f));
if (failed(snapshot, budget, driver)) return snapshot;
driver.setInput(false, true, true);
@@ -135,10 +143,8 @@ final class LevelEightScenario {
private Result finishFromFarSide(LevelPlaytestBot.Driver driver,
PlaytestSimulationBudget budget,
GameView.AutomationSnapshot snapshot, String step) {
snapshot = await(driver, budget, "Reach the open-door jump",
LevelPlaytestBot.Input.right(),
current -> current.playerX() >= 1_730f && current.allDoorsOpen());
if (failed(snapshot, budget, driver)) {
snapshot = positionForFinalJump(driver, budget);
if (failed(snapshot, budget, driver) || !snapshot.allDoorsOpen()) {
return failure("Reach the open-door jump", snapshot, budget, driver);
}
@@ -154,14 +160,62 @@ final class LevelEightScenario {
+ "; " + state(snapshot));
}
private GameView.AutomationSnapshot pulseLeftJumpThenAwait(
private GameView.AutomationSnapshot positionForFinalJump(
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
) {
lastStep = "Reach the open-door jump";
GameView.AutomationSnapshot snapshot = driver.snapshot();
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
&& snapshot.playerAlive() && !snapshot.completed() && snapshot.allDoorsOpen()) {
if (snapshot.playerOnGround()
&& snapshot.playerX() >= 1_690f && snapshot.playerX() <= 1_730f) {
driver.setInput(false, false, false);
return snapshot;
}
if (snapshot.playerX() > 1_730f) {
driver.setInput(true, false, false);
} else {
driver.setInput(false, true, false);
}
driver.waitForNextDecision(DECISION_MILLIS);
snapshot = driver.snapshot();
}
driver.setInput(false, false, false);
return snapshot;
}
private GameView.AutomationSnapshot pulseRightJumpThenAwait(
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step,
LevelPlaytestBot.SnapshotCondition completion
) {
driver.setInput(true, false, true);
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
return await(driver, budget, step, LevelPlaytestBot.Input.right(), completion);
}
private GameView.AutomationSnapshot positionBeneathRecoveryRelay(
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
) {
lastStep = "Return beneath the recovery relay";
GameView.AutomationSnapshot snapshot = driver.snapshot();
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
&& snapshot.playerAlive() && !snapshot.completed()) {
if (snapshot.playerOnGround()
&& snapshot.playerX() >= 1_620f && snapshot.playerX() <= 1_650f) {
driver.setInput(false, false, false);
return snapshot;
}
if (snapshot.playerX() > 1_650f) {
driver.setInput(true, false, false);
return await(driver, budget, step, LevelPlaytestBot.Input.left(), completion);
} else {
driver.setInput(false, true, false);
}
driver.waitForNextDecision(DECISION_MILLIS);
snapshot = driver.snapshot();
}
driver.setInput(false, false, false);
return snapshot;
}
private GameView.AutomationSnapshot await(
@@ -224,6 +278,7 @@ final class LevelEightScenario {
+ ", platformY=" + snapshot.firstMovingPlatformY()
+ ", onMovingPlatform=" + snapshot.playerOnMovingPlatform()
+ ", onGround=" + snapshot.playerOnGround()
+ ", platformPowered=" + snapshot.firstMovingPlatformPowered();
+ ", platformPowered=" + snapshot.firstMovingPlatformPowered()
+ ", platformMovingRight=" + snapshot.firstMovingPlatformMovingRight();
}
}
@@ -67,15 +67,16 @@ public final class EarlyLevelScenarios {
.step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(),
EarlyLevelScenarios::respawnedAtStart)
.step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive()
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerX() >= 280f)
&& snapshot.playerX() >= 390f))
.step("Take off before the saw", 1_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerAlive()
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 680f)
&& snapshot.playerY() <= 620f))
.step("Clear the saw and plate body", 3_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 700f)
LevelPlaytestBot.whileDeathsAtMost(1, snapshot ->
snapshot.playerAlive() && snapshot.playerX() >= 700f))
.step("Reach the relic through the open door", 5_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
@@ -59,6 +59,7 @@ public final class GameView extends View implements Runnable {
private final float firstMovingPlatformY;
private final boolean playerOnMovingPlatform;
private final boolean firstMovingPlatformPowered;
private final boolean firstMovingPlatformMovingRight;
private final float firstCorpseX;
private final boolean playerOnGround;
@@ -68,7 +69,7 @@ public final class GameView extends View implements Runnable {
float playerX, float playerY, int deaths, int corpseCount) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
Float.NaN, Float.NaN, false, false, Float.NaN, false);
Float.NaN, Float.NaN, false, false, false, Float.NaN, false);
}
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
@@ -79,6 +80,21 @@ public final class GameView extends View implements Runnable {
boolean playerOnMovingPlatform,
boolean firstMovingPlatformPowered, float firstCorpseX,
boolean playerOnGround) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
firstMovingPlatformPowered, false, firstCorpseX, playerOnGround);
}
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
long elapsedMilliseconds,
float playerX, float playerY, int deaths, int corpseCount,
float firstMovingPlatformX, float firstMovingPlatformY,
boolean playerOnMovingPlatform,
boolean firstMovingPlatformPowered,
boolean firstMovingPlatformMovingRight, float firstCorpseX,
boolean playerOnGround) {
this.completed = completed;
this.playerAlive = playerAlive;
this.allDoorsOpen = allDoorsOpen;
@@ -94,6 +110,7 @@ public final class GameView extends View implements Runnable {
this.firstMovingPlatformY = firstMovingPlatformY;
this.playerOnMovingPlatform = playerOnMovingPlatform;
this.firstMovingPlatformPowered = firstMovingPlatformPowered;
this.firstMovingPlatformMovingRight = firstMovingPlatformMovingRight;
this.firstCorpseX = firstCorpseX;
this.playerOnGround = playerOnGround;
}
@@ -113,6 +130,7 @@ public final class GameView extends View implements Runnable {
public float firstMovingPlatformY() { return firstMovingPlatformY; }
public boolean playerOnMovingPlatform() { return playerOnMovingPlatform; }
public boolean firstMovingPlatformPowered() { return firstMovingPlatformPowered; }
public boolean firstMovingPlatformMovingRight() { return firstMovingPlatformMovingRight; }
public float firstCorpseX() { return firstCorpseX; }
public boolean playerOnGround() { return playerOnGround; }
}
@@ -1958,11 +1976,13 @@ public final class GameView extends View implements Runnable {
float firstMovingPlatformX = Float.NaN;
float firstMovingPlatformY = Float.NaN;
boolean firstMovingPlatformPowered = false;
boolean firstMovingPlatformMovingRight = false;
for (PlatformState state : platformStates) {
if (state.definition.isMoving()) {
firstMovingPlatformX = state.x;
firstMovingPlatformY = state.y;
firstMovingPlatformPowered = signalActive(state.definition.controlId);
firstMovingPlatformMovingRight = state.x > state.previousX;
break;
}
}
@@ -1986,6 +2006,7 @@ public final class GameView extends View implements Runnable {
firstMovingPlatformY,
playerOnMovingPlatform,
firstMovingPlatformPowered,
firstMovingPlatformMovingRight,
corpses.isEmpty() ? Float.NaN : corpses.get(0).x,
player.onGround
);
@@ -6,6 +6,7 @@ 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;
@@ -58,7 +59,11 @@ public final class LevelPlaytestBot {
return snapshot -> {
if (snapshot.deaths() > maximumDeaths) {
throw new AssertionError("Route expected at most " + maximumDeaths
+ " deaths but observed " + snapshot.deaths());
+ " deaths but observed " + snapshot.deaths()
+ "; x=" + snapshot.playerX()
+ ", y=" + snapshot.playerY()
+ ", alive=" + snapshot.playerAlive()
+ ", grounded=" + snapshot.playerOnGround());
}
return completion.reached(snapshot);
};
@@ -196,7 +201,7 @@ public final class LevelPlaytestBot {
}
driver.setInput(step.input.moveLeft, step.input.moveRight, step.input.jump);
driver.waitForNextDecision(32L);
driver.waitForNextDecision(DECISION_MILLIS);
}
driver.setInput(false, false, false);
@@ -4,6 +4,12 @@ package com.projectsacrifice.game;
final class MovingPlatformLaunchPolicy {
private MovingPlatformLaunchPolicy() {}
static boolean readyToBoard(GameView.AutomationSnapshot snapshot, float maximumPlatformX) {
return snapshot.firstMovingPlatformPowered()
&& snapshot.firstMovingPlatformMovingRight()
&& snapshot.firstMovingPlatformX() <= maximumPlatformX;
}
static boolean ready(GameView.AutomationSnapshot snapshot, float minimumPlatformX) {
return snapshot.playerOnMovingPlatform()
&& snapshot.playerOnGround()
@@ -6,22 +6,35 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class MovingPlatformLaunchPolicyTest {
@Test
public void boardingWaitsForThePoweredOutboundPhaseNearTheLeftFloor() {
assertFalse(MovingPlatformLaunchPolicy.readyToBoard(
snapshot(1_000f, false, true, true, false), 1_050f));
assertFalse(MovingPlatformLaunchPolicy.readyToBoard(
snapshot(1_000f, false, true, false, true), 1_050f));
assertFalse(MovingPlatformLaunchPolicy.readyToBoard(
snapshot(1_100f, false, true, true, true), 1_050f));
assertTrue(MovingPlatformLaunchPolicy.readyToBoard(
snapshot(1_000f, false, true, true, true), 1_050f));
}
@Test
public void launchRequiresStablePoweredSupportInTheLaunchZone() {
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(910f, true, true, true), 1_150f));
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, false, true, true), 1_150f));
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, false, true), 1_150f));
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, false), 1_150f));
assertTrue(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, true), 1_150f));
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(910f, true, true, true, true), 1_150f));
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, false, true, true, true), 1_150f));
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, false, true, true), 1_150f));
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, false, true), 1_150f));
assertTrue(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, true, true), 1_150f));
}
private static GameView.AutomationSnapshot snapshot(
float platformX, boolean supported, boolean grounded, boolean powered
float platformX, boolean supported, boolean grounded, boolean powered,
boolean movingRight
) {
return new GameView.AutomationSnapshot(
false, true, true, false, false, 8,
4_000L, 1_100f, 670f, 0, 0,
platformX, 770f, supported, powered, Float.NaN, grounded
platformX, 770f, supported, powered, movingRight, Float.NaN, grounded
);
}
}