From a8f5ddc0a996e2abe736ee755399960af06515e1 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 02:05:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B7=D0=B4=D0=BD=D0=B8=D1=85=20?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B9=20=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/LateLevelPlaythroughTest.java | 154 ++++++++++++ .../game/LateLevelScenarios.java | 222 ++++++++++++++++++ .../projectsacrifice/game/LevelCatalog.java | 2 +- .../game/LevelCatalogTest.java | 18 ++ docs/LEVEL_SOLUTION_GRAPHS.md | 2 +- 5 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/LateLevelPlaythroughTest.java create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelPlaythroughTest.java b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelPlaythroughTest.java new file mode 100644 index 0000000..f399d65 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelPlaythroughTest.java @@ -0,0 +1,154 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +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; + +/** Production-input acceptance coverage for levels 6, 7, 9 and 10. */ +@RunWith(AndroidJUnit4.class) +public final class LateLevelPlaythroughTest { + private static final int CONSECUTIVE_RUNS = 3; + + @Test + public void frozenVolunteerIsUsedToCompleteLevelSixThreeTimes() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + GameDriver driver = GameDriver.from(activity); + + for (int run = 1; run <= CONSECUTIVE_RUNS; run++) { + LevelPlaytestBot.Result result = new LevelPlaytestBot().run( + driver, + LateLevelScenarios.levelSix(), + 30_000L + ); + + assertTrue("run=" + run + "; " + result.diagnostic(), result.completed()); + assertEquals("run=" + run + "; intended route uses one frozen volunteer", + 1, result.deaths()); + assertEquals("run=" + run + "; frozen block must remain in the puzzle", + 1, result.corpseCount()); + } + } + } + + @Test + public void conductiveVolunteerKeepsLevelSevenDoorOpenThreeTimes() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + GameDriver driver = GameDriver.from(activity); + + for (int run = 1; run <= CONSECUTIVE_RUNS; run++) { + LevelPlaytestBot.Result result = new LevelPlaytestBot().run( + driver, + LateLevelScenarios.levelSeven(), + 25_000L + ); + + assertTrue("run=" + run + "; " + result.diagnostic(), result.completed()); + assertEquals("run=" + run + "; intended route uses one conductive volunteer", + 1, result.deaths()); + assertEquals("run=" + run + "; conductive body must remain in the relay", + 1, result.corpseCount()); + assertTrue("run=" + run + "; conductive relay must open the door", + result.doorOpened()); + } + } + } + + @Test + public void weightAndPowerOpenLevelNineDoorThreeTimes() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + GameDriver driver = GameDriver.from(activity); + + for (int run = 1; run <= CONSECUTIVE_RUNS; run++) { + LevelPlaytestBot.Result result = new LevelPlaytestBot().run( + driver, + LateLevelScenarios.levelNine(), + 35_000L + ); + + assertTrue("run=" + run + "; " + result.diagnostic(), result.completed()); + assertEquals("run=" + run + "; weight and power require two volunteers", + 2, result.deaths()); + assertEquals("run=" + run + "; both signal bodies must remain", + 2, result.corpseCount()); + assertTrue("run=" + run + "; both signals must open the AND door", + result.doorOpened()); + } + } + } + + @Test + public void masteryProcessionCompletesLevelTenThreeTimes() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + GameDriver driver = GameDriver.from(activity); + + for (int run = 1; run <= CONSECUTIVE_RUNS; run++) { + LevelPlaytestBot.Result result = new LevelPlaytestBot().run( + driver, + LateLevelScenarios.levelTen(), + 55_000L + ); + + assertTrue("run=" + run + "; " + result.diagnostic(), result.completed()); + assertEquals("run=" + run + "; mastery route uses three volunteers", + 3, result.deaths()); + assertEquals("run=" + run + "; launch, freeze and relay bodies must remain", + 3, result.corpseCount()); + assertTrue("run=" + run + "; conductive relay must open the final door", + result.doorOpened()); + } + } + } + + private static final class GameDriver implements LevelPlaytestBot.Driver { + private final Instrumentation instrumentation; + private final GameView gameView; + + private GameDriver(Instrumentation instrumentation, GameView gameView) { + this.instrumentation = instrumentation; + this.gameView = gameView; + } + + static GameDriver from(ActivityScenario activity) { + GameView[] view = new GameView[1]; + activity.onActivity(current -> view[0] = current.gameViewForAutomation()); + return new GameDriver(InstrumentationRegistry.getInstrumentation(), view[0]); + } + + @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() { + 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("Late-level bot interrupted", interrupted); + } + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java new file mode 100644 index 0000000..6ead3fb --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java @@ -0,0 +1,222 @@ +package com.projectsacrifice.game; + +/** Declarative intended-route scenarios for the late vertical-slice levels. */ +final class LateLevelScenarios { + private LateLevelScenarios() {} + + static LevelPlaytestBot.Scenario levelSix() { + return LevelPlaytestBot.Scenario.builder(6, "Level 6 - frozen corpse step") + .step("Freeze one volunteer", 4_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1) + .step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) + .step("Approach the frozen block", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 390f) + .step("Jump toward the frozen block", 1_500L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 640f) + .step("Stand on the frozen block", 2_500L, LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerX() >= 626f + && snapshot.playerY() >= 668f + && snapshot.playerY() <= 690f) + .step("Jump from the block to the first ledge", 2_500L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 750f + && snapshot.playerY() <= 505f) + .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, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 1_400f + && snapshot.playerY() <= 520f) + .step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + } + + static LevelPlaytestBot.Scenario levelSeven() { + return LevelPlaytestBot.Scenario.builder(7, "Level 7 - conductive relay") + .step("Power the relay with one volunteer", 4_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1) + .step("Observe the powered door", 1_500L, LevelPlaytestBot.Input.none(), + GameView.AutomationSnapshot::allDoorsOpen) + .step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) + .step("Approach the live relay", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 460f) + .step("Take off before the conductive relay", 1_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f) + .step("Clear the conductive relay", 2_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 780f) + .step("Pass the powered door", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 1_230f) + .step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + } + + static LevelPlaytestBot.Scenario levelNine() { + return LevelPlaytestBot.Scenario.builder(9, "Level 9 - weight AND power") + .step("Leave one volunteer on the weight plate", 4_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1 + && !snapshot.allDoorsOpen()) + .step("Wait for the second volunteer", 3_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) + .step("Approach the weighted spikes", 2_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 390f) + .step("Jump over the weighted spikes", 2_500L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + .step("Approach the unpowered relay", 2_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 820f) + .step("Power the relay with the second volunteer", 2_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() == 2 + && snapshot.corpseCount() == 2) + .step("Observe weight AND power opening the door", 1_500L, + LevelPlaytestBot.Input.none(), + GameView.AutomationSnapshot::allDoorsOpen) + .step("Wait for the final volunteer", 3_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) + .step("Return to the weighted spikes", 2_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 390f) + .step("Clear the weighted spikes", 2_500L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + .step("Approach the powered relay", 2_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 720f) + .step("Take off before the powered relay", 1_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f) + .step("Clear the powered relay", 2_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_040f) + .step("Pass the AND door", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 1_610f) + .step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + } + + static LevelPlaytestBot.Scenario levelTen() { + return LevelPlaytestBot.Scenario.builder(10, "Level 10 - mastery procession") + .step("Launch one volunteer toward the lift plate", 4_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1) + .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(), + snapshot -> snapshot.playerX() >= 350f) + .step("Take off before the launch saw", 1_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f) + .step("Clear the launch saw", 2_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + .step("Approach the delivered lift body", 1_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 730f) + .step("Jump over the delivered lift body", 2_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 950f) + .step("Enter the freezer", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() == 2 + && snapshot.corpseCount() == 2) + .step("Wait for the relay volunteer", 3_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) + .step("Return to the launch saw", 2_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 350f) + .step("Take off before the saw again", 1_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f) + .step("Clear the saw again", 2_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + .step("Approach the lift body again", 1_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 730f) + .step("Clear the lift body again", 2_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 900f) + .step("Prepare the frozen-block jump", 2_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerY() >= 740f) + .step("Jump over the frozen block", 2_500L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_240f) + .step("Power the final relay", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() == 3 + && snapshot.corpseCount() == 3) + .step("Observe the final door open", 1_500L, + LevelPlaytestBot.Input.none(), + GameView.AutomationSnapshot::allDoorsOpen) + .step("Wait for the final volunteer", 3_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) + .step("Approach the saw for the final crossing", 2_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 350f) + .step("Take off for the final saw crossing", 1_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f) + .step("Clear the saw for the final crossing", 2_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + .step("Approach the retained lift body", 1_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 730f) + .step("Clear the retained lift body", 2_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 900f) + .step("Prepare the final frozen-block jump", 2_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerAlive() && snapshot.playerY() >= 740f) + .step("Clear the frozen block", 2_500L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_240f) + .step("Approach the powered relay", 2_500L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 1_390f) + .step("Clear the powered relay", 2_500L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_650f) + .step("Approach the lift", 2_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 1_730f) + .step("Board the moving lift", 3_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 1_790f + && snapshot.playerY() <= 670f) + .step("Ride the lift to the upper door", 5_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerY() <= 310f) + .step("Move to the raised lift edge", 2_000L, + LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 2_000f + && snapshot.playerY() <= 340f) + .step("Jump through the door to the upper gallery", 3_000L, + LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 2_400f + && snapshot.playerY() <= 360f) + .step("Reach the final relic", 4_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java b/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java index 9661122..a3eca67 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelCatalog.java @@ -90,7 +90,7 @@ public final class LevelCatalog { 6, "Cold Comfort", "The frost makes a solid block. Use the frozen volunteer as a step.", 2150, 130, 760, goal(1880, 500), 1, 65, platforms(floor(0, 1120), ledge(810, 590, 520), ledge(1430, 610, 570)), - hazards(hazard(FREEZER, 690, 750, 100, 130)), switches(), doors() + hazards(hazard(FREEZER, 690, 776, 100, 104)), switches(), doors() ); } diff --git a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java index 6911df1..ca3856b 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java @@ -82,6 +82,24 @@ public final class LevelCatalogTest { assertFalse(finale.doors.isEmpty()); } + @Test + public void levelSixFreezerKillsOnTheFloorButNotOnTopOfItsFrozenBlock() { + LevelDefinition level = LevelCatalog.get(5); + LevelDefinition.Hazard freezer = null; + for (LevelDefinition.Hazard hazard : level.hazards) { + if (hazard.type == LevelDefinition.HazardType.FREEZER) freezer = hazard; + } + assertTrue("Level 6 must contain its freezer", freezer != null); + + float frozenBlockTop = 880f - 104f; + assertTrue("A floor-running volunteer must still hit the freezer", + GameMath.intersects(688f, 780f, 62f, 100f, + freezer.box.x, freezer.box.y, freezer.box.width, freezer.box.height)); + assertFalse("A volunteer standing on the frozen block must clear the freezer", + GameMath.intersects(688f, frozenBlockTop - 100f, 62f, 100f, + freezer.box.x, freezer.box.y, freezer.box.width, freezer.box.height)); + } + @Test public void everyDoorAndControlledPlatformHasADeclaredSignalSource() { for (LevelDefinition level : LevelCatalog.all()) { diff --git a/docs/LEVEL_SOLUTION_GRAPHS.md b/docs/LEVEL_SOLUTION_GRAPHS.md index 1cf544f..8af5158 100644 --- a/docs/LEVEL_SOLUTION_GRAPHS.md +++ b/docs/LEVEL_SOLUTION_GRAPHS.md @@ -146,7 +146,7 @@ Door закрывает путь к goal **Статус:** предполагаемый маршрут, ботом не доказан. **Ожидаемые смерти:** `1` (`targetDeaths=1`). -**Источник:** `LevelCatalog.level6()`; `FREEZER=(690,750,100,130)`; floor заканчивается на `x=1120`; уступы `(810,590,520)` и `(1430,610,570)`; `goal=(1880,500,86,110)`. +**Источник:** `LevelCatalog.level6()`; `FREEZER=(690,776,100,104)`; floor заканчивается на `x=1120`; уступы `(810,590,520)` и `(1430,610,570)`; `goal=(1880,500,86,110)`. ### Solution graph 6