Устранить softlock восьмого уровня

This commit is contained in:
2026-08-18 02:41:45 +03:00
parent 3ed6d9ebab
commit 70106e29d8
4 changed files with 309 additions and 8 deletions
@@ -0,0 +1,90 @@
package com.projectsacrifice.game;
import android.app.Instrumentation;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/** Device acceptance tests for the level-8 timed-platform puzzle and recovery route. */
@RunWith(AndroidJUnit4.class)
public final class LevelEightPlaythroughTest {
private static final int REQUIRED_CONSECUTIVE_RUNS = 3;
@Test
public void expiredClockCanBeRearmedFromTheFarSideWithoutRestart() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
AndroidDriver driver = driver(activity);
for (int run = 1; run <= REQUIRED_CONSECUTIVE_RUNS; run++) {
LevelEightScenario.Result result = new LevelEightScenario()
.recoverAfterClockExpires(driver, 20_000L);
assertTrue("Recovery run " + run + ": " + result.diagnostic(), result.completed());
assertEquals("Recovery must not hide a reset/death shortcut", 0, result.deaths());
}
}
}
@Test
public void intendedTimedPlatformRouteCompletesThreeConsecutiveTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
AndroidDriver driver = driver(activity);
for (int run = 1; run <= REQUIRED_CONSECUTIVE_RUNS; run++) {
LevelEightScenario.Result result = new LevelEightScenario()
.completeIntendedRoute(driver, 15_000L);
assertTrue("Intended run " + run + ": " + result.diagnostic(), result.completed());
assertEquals("The intended route is deathless", 0, result.deaths());
}
}
}
private static AndroidDriver driver(ActivityScenario<MainActivity> activity) {
final GameView[] view = new GameView[1];
activity.onActivity(mainActivity -> view[0] = mainActivity.gameViewForAutomation());
return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]);
}
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView gameView;
AndroidDriver(Instrumentation instrumentation, GameView gameView) {
this.instrumentation = instrumentation;
this.gameView = gameView;
}
@Override
public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
}
@Override
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
instrumentation.runOnMainSync(
() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
}
@Override
public GameView.AutomationSnapshot snapshot() {
final GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> value[0] = gameView.automationSnapshot());
return value[0];
}
@Override
public void waitForNextDecision(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Level-8 bot interrupted", interrupted);
}
}
}
}
@@ -0,0 +1,201 @@
package com.projectsacrifice.game;
/** Black-box level-8 scenarios driven only through the production input boundary. */
final class LevelEightScenario {
private static final long DECISION_MILLIS = 32L;
static final class Result {
private final boolean completed;
private final int deaths;
private final String diagnostic;
Result(boolean completed, int deaths, String diagnostic) {
this.completed = completed;
this.deaths = deaths;
this.diagnostic = diagnostic;
}
boolean completed() { return completed; }
int deaths() { return deaths; }
String diagnostic() { return diagnostic; }
}
Result recoverAfterClockExpires(LevelPlaytestBot.Driver driver, long timeoutMillis) {
long deadline = driver.currentTimeMillis() + timeoutMillis;
driver.startLevel(8);
GameView.AutomationSnapshot snapshot = crossPit(driver, deadline);
if (failed(snapshot, deadline, driver)) {
return failure("Cross the pit before the clock expires", snapshot);
}
snapshot = await(driver, deadline, "Wait for the far-side trap",
LevelPlaytestBot.Input.none(),
current -> current.playerX() >= 1_510f && !current.allDoorsOpen());
if (failed(snapshot, deadline, driver)) {
return failure("Wait for the far-side trap", snapshot);
}
snapshot = await(driver, deadline, "Return beneath the recovery relay",
LevelPlaytestBot.Input.left(),
current -> current.playerX() <= 1_680f && current.playerY() >= 779f);
if (failed(snapshot, deadline, driver)) {
return failure("Return beneath the recovery relay", snapshot);
}
snapshot = pulseLeftJumpThenAwait(driver, deadline, "Jump into the recovery relay",
current -> current.allDoorsOpen());
if (failed(snapshot, deadline, driver)) {
return failure("Jump into the recovery relay", snapshot);
}
snapshot = await(driver, deadline, "Land after rearming the clock",
LevelPlaytestBot.Input.none(), current -> current.playerY() >= 779f);
if (failed(snapshot, deadline, driver)) {
return failure("Land after rearming the clock", snapshot);
}
return finishFromFarSide(driver, deadline, snapshot, "Finish after recovery");
}
Result completeIntendedRoute(LevelPlaytestBot.Driver driver, long timeoutMillis) {
long deadline = driver.currentTimeMillis() + timeoutMillis;
driver.startLevel(8);
GameView.AutomationSnapshot snapshot = crossPit(driver, deadline);
if (failed(snapshot, deadline, driver)) {
return failure("Cross the pit before the clock expires", snapshot);
}
return finishFromFarSide(driver, deadline, snapshot, "Finish on the original timer");
}
private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver, long deadline) {
GameView.AutomationSnapshot snapshot = await(driver, deadline, "Trigger the left timer",
LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::allDoorsOpen);
if (failed(snapshot, deadline, driver)) return snapshot;
long timerStartedAt = snapshot.elapsedMilliseconds();
snapshot = await(driver, deadline, "Approach the pit",
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f);
if (failed(snapshot, deadline, driver)) return snapshot;
snapshot = await(driver, deadline, "Wait for the moving platform",
LevelPlaytestBot.Input.none(),
current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L);
if (failed(snapshot, deadline, driver)) return snapshot;
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
snapshot = await(driver, deadline, "Land on the moving platform",
LevelPlaytestBot.Input.right(),
current -> current.elapsedMilliseconds() >= timerStartedAt + 3_200L);
if (failed(snapshot, deadline, driver)) return snapshot;
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
snapshot = await(driver, deadline, "Jump from the moving platform to the far floor",
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f);
if (failed(snapshot, deadline, driver)) return snapshot;
snapshot = awaitFarFloorLandingOnOriginalTimer(driver, deadline);
if (failed(snapshot, deadline, driver)) return snapshot;
return await(driver, deadline, "Move under the relay after landing",
LevelPlaytestBot.Input.right(),
current -> current.playerX() >= 1_600f
&& current.playerY() >= 779f && current.allDoorsOpen());
}
private GameView.AutomationSnapshot awaitFarFloorLandingOnOriginalTimer(
LevelPlaytestBot.Driver driver, long deadline
) {
boolean moveLeft = true;
GameView.AutomationSnapshot snapshot = driver.snapshot();
while (driver.currentTimeMillis() < deadline && snapshot.playerAlive()
&& !(snapshot.playerX() >= 1_510f && snapshot.playerX() < 1_580f
&& snapshot.playerY() >= 779f && snapshot.allDoorsOpen())) {
driver.setInput(moveLeft, !moveLeft, false);
moveLeft = !moveLeft;
driver.waitForNextDecision(DECISION_MILLIS);
snapshot = driver.snapshot();
}
driver.setInput(false, false, false);
return snapshot;
}
private Result finishFromFarSide(LevelPlaytestBot.Driver driver, long deadline,
GameView.AutomationSnapshot snapshot, String step) {
snapshot = await(driver, deadline, "Reach the open-door jump",
LevelPlaytestBot.Input.right(),
current -> current.playerX() >= 1_730f && current.allDoorsOpen());
if (failed(snapshot, deadline, driver)) {
return failure("Reach the open-door jump", snapshot);
}
driver.setInput(false, true, true);
driver.waitForNextDecision(64L);
driver.setInput(false, true, false);
snapshot = await(driver, deadline, step, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed);
driver.setInput(false, false, false);
if (!snapshot.completed()) return failure(step, snapshot);
return new Result(true, snapshot.deaths(), step + " completed; " + state(snapshot));
}
private GameView.AutomationSnapshot pulseLeftJumpThenAwait(
LevelPlaytestBot.Driver driver, long deadline, String step,
LevelPlaytestBot.SnapshotCondition completion
) {
driver.setInput(true, false, true);
driver.waitForNextDecision(64L);
driver.setInput(true, false, false);
return await(driver, deadline, step, LevelPlaytestBot.Input.left(), completion);
}
private GameView.AutomationSnapshot await(
LevelPlaytestBot.Driver driver, long deadline, String step,
LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion
) {
GameView.AutomationSnapshot snapshot = driver.snapshot();
while (driver.currentTimeMillis() < deadline && snapshot.playerAlive()
&& !snapshot.completed() && !completion.reached(snapshot)) {
setInput(driver, input);
driver.waitForNextDecision(DECISION_MILLIS);
snapshot = driver.snapshot();
}
if (driver.currentTimeMillis() >= deadline || !snapshot.playerAlive()) {
driver.setInput(false, false, false);
}
return snapshot;
}
private static void setInput(LevelPlaytestBot.Driver driver, LevelPlaytestBot.Input input) {
if (input == LevelPlaytestBot.Input.right()) {
driver.setInput(false, true, false);
} else if (input == LevelPlaytestBot.Input.left()) {
driver.setInput(true, false, false);
} else if (input == LevelPlaytestBot.Input.rightJump()) {
driver.setInput(false, true, true);
} else {
driver.setInput(false, false, false);
}
}
private static boolean failed(GameView.AutomationSnapshot snapshot, long deadline,
LevelPlaytestBot.Driver driver) {
return !snapshot.completed()
&& (!snapshot.playerAlive() || driver.currentTimeMillis() >= deadline);
}
private static Result failure(String step, GameView.AutomationSnapshot snapshot) {
return new Result(false, snapshot.deaths(), step + " failed; " + state(snapshot));
}
private static String state(GameView.AutomationSnapshot snapshot) {
return "x=" + snapshot.playerX()
+ ", y=" + snapshot.playerY()
+ ", alive=" + snapshot.playerAlive()
+ ", doorOpen=" + snapshot.allDoorsOpen()
+ ", completed=" + snapshot.completed()
+ ", elapsedMs=" + snapshot.elapsedMilliseconds()
+ ", deaths=" + snapshot.deaths();
}
}
@@ -106,14 +106,17 @@ public final class LevelCatalog {
private static LevelDefinition level8() {
return level(
8, "Borrowed Time", "The blue switch lasts five seconds. Keep moving.",
8, "Borrowed Time", "Jump for the far blue relay if the timed gate closes.",
2450, 130, 760, goal(2220, 600), 0, 38,
platforms(
floor(0, 900), floor(1510, 940), ledge(1980, 710, 470),
moving(990, 770, 240, 34, 380, 0, 4.0f, "clock")
),
hazards(hazard(PIT, 900, 880, 610, 200)),
switches(sw(TIMED, 560, 810, 90, 70, "clock", 5.5f, 1)),
switches(
sw(TIMED, 560, 810, 90, 70, "clock", 6.0f, 1),
sw(TIMED, 1600, 650, 90, 70, "clock", 3.5f, 1)
),
doors(door(1770, 560, 82, 320, "clock"))
);
}