Устранить softlock восьмого уровня
This commit is contained in:
@@ -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() {
|
private static LevelDefinition level8() {
|
||||||
return level(
|
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,
|
2450, 130, 760, goal(2220, 600), 0, 38,
|
||||||
platforms(
|
platforms(
|
||||||
floor(0, 900), floor(1510, 940), ledge(1980, 710, 470),
|
floor(0, 900), floor(1510, 940), ledge(1980, 710, 470),
|
||||||
moving(990, 770, 240, 34, 380, 0, 4.0f, "clock")
|
moving(990, 770, 240, 34, 380, 0, 4.0f, "clock")
|
||||||
),
|
),
|
||||||
hazards(hazard(PIT, 900, 880, 610, 200)),
|
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"))
|
doors(door(1770, 560, 82, 320, "clock"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,15 +197,15 @@ Door закрывает путь к goal
|
|||||||
|
|
||||||
## Уровень 8 — `Borrowed Time`
|
## Уровень 8 — `Borrowed Time`
|
||||||
|
|
||||||
**Статус:** intended route и recovery route доказаны ботом, каждый по 3/3 последовательных прохождения без смертей.
|
**Статус:** intended route и восстановление после истечения таймера доказаны production-input ботом, по три раза подряд.
|
||||||
**Ожидаемые смерти:** `0` (`targetDeaths=0`).
|
**Ожидаемые смерти:** `0` (`targetDeaths=0`).
|
||||||
**Источник:** `LevelCatalog.level8()`; `TIMED=(560,810,90,70)`, `signalId="clock"`, duration `5.5`; moving platform `(990,770,240,34)`, travel `(380,0)`, period `4.0`, `controlId="clock"`; `PIT=(900,880,610,200)`; дверь `(1770,560,82,320)` требует `"clock"`; `goal=(2220,600,86,110)`.
|
**Источник:** `LevelCatalog.level8()`; основной `TIMED=(560,810,90,70)`, `signalId="clock"`, duration `6.0`; recovery relay `TIMED=(1600,650,90,70)`, duration `3.5`; moving platform `(990,770,240,34)`, travel `(380,0)`, period `4.0`, `controlId="clock"`; `PIT=(900,880,610,200)`; дверь `(1770,560,82,320)` требует `"clock"`; `goal=(2220,600,86,110)`.
|
||||||
|
|
||||||
### Solution graph 8
|
### Solution graph 8
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PIT разрывает floor, а Door закрывает дальнейший путь
|
PIT разрывает floor, а Door закрывает дальнейший путь
|
||||||
→ войти в TIMED и запустить signal "clock" на 5.5 s
|
→ войти в TIMED и запустить signal "clock" на 6.0 s
|
||||||
→ "clock" одновременно запускает horizontal moving platform и открывает Door
|
→ "clock" одновременно запускает horizontal moving platform и открывает Door
|
||||||
→ без задержки добежать до края floor x=900
|
→ без задержки добежать до края floor x=900
|
||||||
→ прыгнуть на moving platform и пересечь PIT
|
→ прыгнуть на moving platform и пересечь PIT
|
||||||
@@ -213,13 +213,20 @@ PIT разрывает floor, а Door закрывает дальнейший п
|
|||||||
→ пройти Door x=1770..1852 до истечения "clock"
|
→ пройти Door x=1770..1852 до истечения "clock"
|
||||||
→ запрыгнуть на ledge x=1980..2450
|
→ запрыгнуть на ledge x=1980..2450
|
||||||
→ пересечь goal и получить COMPLETE без смерти
|
→ пересечь goal и получить COMPLETE без смерти
|
||||||
|
|
||||||
|
Ветка восстановления после пересечения PIT:
|
||||||
|
→ дождаться истечения "clock" на правом floor x≥1510
|
||||||
|
→ Door закрывается и подтверждает прежнюю trap-state
|
||||||
|
→ намеренно прыгнуть в высокий recovery relay x=1600, y=650
|
||||||
|
→ повторно запустить "clock" на 3.5 s
|
||||||
|
→ пройти Door и достичь goal без смерти/restart
|
||||||
```
|
```
|
||||||
|
|
||||||
**Механика и сигналы:** фронт контакта `TIMED` → `timedSignals["clock"]=5.5` → движение платформы по `travelX=380` и открытие Door; таймер уменьшается каждый update.
|
**Механика и сигналы:** фронт контакта основного `TIMED` → `timedSignals["clock"]=6.0` → движение платформы по `travelX=380` и открытие Door; высокий relay выставляет тот же сигнал на `3.5 s`. Обычный бег проходит под relay, поэтому original route не активирует восстановление автоматически. Строгий grounded-checkpoint выявил нестабильное закрытие Door на `5.5 s`; добавленные `0.5 s` дают запас на проход двери, не меняя crossing или recovery.
|
||||||
|
|
||||||
**Bot checkpoints:** `"clock"` active с remaining time; platform `x>990`; игрок получает её `supportIndex`; затем `playerX≥1510`; Door остаётся open при `playerX>1852`; финально `completed=true`, `deaths=0`. Требуется добавить в snapshot remaining time и координаты платформы.
|
**Bot checkpoints:** основной timer активен; игрок двумя прыжками пересекает PIT через moving platform; перед движением к relay явно подтверждены far floor `playerX≥1510`, `playerY≥779` и всё ещё открытая Door. Intended route проходит Door на первом таймере. Recovery route ждёт `allDoorsOpen=false` справа от PIT, прыжком получает `allDoorsOpen=true`, проходит Door и завершает уровень. Оба маршрута финально дают `completed=true`, `deaths=0`; каждый выполнен три раза подряд на Android 16.
|
||||||
|
|
||||||
**Softlock/неопределённость:** исходный blocker-softlock был воспроизведён после истечения `"clock"` справа от `PIT`. Добавлен высокий recovery relay того же сигнала, недоступный при обычном беге по floor; intended route и восстановление после истечения таймера отдельно подтверждены по три раза.
|
**Softlock/неопределённость:** прежний blocker устранён: после истечения `"clock"` справа от PIT высокий relay гарантирует restart-free повторное открытие двери. Relay не помогает слева от PIT и не отменяет timed-platform crossing. Оба пути проверены без прямой мутации состояния, teleport, auto-complete или restart.
|
||||||
|
|
||||||
## Уровень 9 — `Two Good Reasons`
|
## Уровень 9 — `Two Good Reasons`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user