diff --git a/app/src/androidTest/java/com/projectsacrifice/game/EarlyLevelPlaythroughTest.java b/app/src/androidTest/java/com/projectsacrifice/game/EarlyLevelPlaythroughTest.java new file mode 100644 index 0000000..eb89abb --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/EarlyLevelPlaythroughTest.java @@ -0,0 +1,116 @@ +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; + +/** Production-input acceptance coverage for the intended routes through levels 1-4. */ +@RunWith(AndroidJUnit4.class) +public final class EarlyLevelPlaythroughTest { + @Test + public void levelOneProvesMovementAndJumpWithoutDeaths() { + for (int pass = 1; pass <= 3; pass++) { + LevelPlaytestBot.Result result = run(EarlyLevelScenarios.levelOne(), 20_000L); + assertTrue(passDiagnostic(pass, result), result.completed()); + assertEquals("Pass " + pass + ": level 1 must not require a volunteer", + 0, result.deaths()); + } + } + + @Test + public void levelTwoBuildsTheIntendedThreeCorpseBridge() { + for (int pass = 1; pass <= 3; pass++) { + LevelPlaytestBot.Result result = run(EarlyLevelScenarios.levelTwo(), 30_000L); + assertTrue(passDiagnostic(pass, result), result.completed()); + assertEquals("Pass " + pass + ": the intended bridge uses three volunteers", + 3, result.deaths()); + assertEquals("Pass " + pass + ": all three bridge bodies must remain", + 3, result.corpseCount()); + } + } + + @Test + public void levelThreeLeavesASawCorpseOnThePlateAndOpensTheDoor() { + for (int pass = 1; pass <= 3; pass++) { + LevelPlaytestBot.Result result = run(EarlyLevelScenarios.levelThree(), 25_000L); + assertTrue(passDiagnostic(pass, result), result.completed()); + assertEquals("Pass " + pass + ": the saw solution requires one volunteer", + 1, result.deaths()); + assertEquals("Pass " + pass + ": the plate body must remain", + 1, result.corpseCount()); + assertTrue("Pass " + pass + ": the corpse must keep the pressure door open", + result.doorOpened()); + } + } + + @Test + public void levelFourTogglesAndRidesTheLiftToTheUpperGallery() { + for (int pass = 1; pass <= 3; pass++) { + LevelPlaytestBot.Result result = run(EarlyLevelScenarios.levelFour(), 30_000L); + assertTrue(passDiagnostic(pass, result), result.completed()); + assertEquals("Pass " + pass + ": the lift route must not require a volunteer", + 0, result.deaths()); + } + } + + private static String passDiagnostic(int pass, LevelPlaytestBot.Result result) { + return "Consecutive pass " + pass + ": " + result.diagnostic(); + } + + private static LevelPlaytestBot.Result run(LevelPlaytestBot.Scenario scenario, long timeoutMillis) { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + final GameView[] view = new GameView[1]; + activity.onActivity(mainActivity -> view[0] = mainActivity.gameViewForAutomation()); + return new LevelPlaytestBot().run( + new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]), + scenario, + timeoutMillis + ); + } + } + + 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("Early-level bot interrupted", interrupted); + } + } + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java new file mode 100644 index 0000000..a3359ce --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java @@ -0,0 +1,96 @@ +package com.projectsacrifice.game; + +/** Declarative intended-route scenarios for the first four teaching levels. */ +public final class EarlyLevelScenarios { + private EarlyLevelScenarios() {} + + public static LevelPlaytestBot.Scenario levelOne() { + return LevelPlaytestBot.Scenario.builder(1, "Level 1: movement and jump") + .step("Approach the first ledge", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 500f) + .step("Jump over the first ledge", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 900f) + .step("Release jump before the upper ledge", 1_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 950f) + .step("Jump over the upper ledge", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 1_330f) + .step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + } + + public static LevelPlaytestBot.Scenario levelTwo() { + return LevelPlaytestBot.Scenario.builder(2, "Level 2: three-corpse bridge") + .step("Place the first bridge body", 4_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() >= 1) + .step("Wait for the second volunteer", 2_000L, LevelPlaytestBot.Input.none(), + EarlyLevelScenarios::respawnedAtStart) + .step("Approach the first bridge body", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 470f) + .step("Jump to the middle of the spikes", 2_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 730f) + .step("Place the second bridge body", 2_000L, LevelPlaytestBot.Input.none(), + snapshot -> snapshot.deaths() >= 2) + .step("Wait for the third volunteer", 2_000L, LevelPlaytestBot.Input.none(), + EarlyLevelScenarios::respawnedAtStart) + .step("Return to the corpse bridge", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 470f) + .step("Jump to the far side of the spikes", 2_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 750f) + .step("Step off the middle bridge body", 2_000L, LevelPlaytestBot.Input.left(), + snapshot -> snapshot.playerX() <= 690f) + .step("Place the third bridge body", 2_000L, LevelPlaytestBot.Input.none(), + snapshot -> snapshot.deaths() >= 3) + .step("Wait for the crossing volunteer", 2_000L, LevelPlaytestBot.Input.none(), + EarlyLevelScenarios::respawnedAtStart) + .step("Approach the completed bridge", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 470f) + .step("Cross the corpse bridge", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 900f) + .step("Release jump before the final ledge", 2_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 1_030f) + .step("Jump over the final ledge", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 1_450f) + .step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + } + + public static LevelPlaytestBot.Scenario levelThree() { + return LevelPlaytestBot.Scenario.builder(3, "Level 3: saw corpse pressure signal") + .step("Leave a volunteer at the saw", 4_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.deaths() >= 1) + .step("Prove the corpse opens the pressure door", 3_000L, LevelPlaytestBot.Input.none(), + GameView.AutomationSnapshot::allDoorsOpen) + .step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(), + EarlyLevelScenarios::respawnedAtStart) + .step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 390f) + .step("Jump over the saw and plate body", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 700f) + .step("Reach the relic through the open door", 5_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + } + + public static LevelPlaytestBot.Scenario levelFour() { + return LevelPlaytestBot.Scenario.builder(4, "Level 4: toggle lift to gallery") + .step("Cross the toggle once and activate the lift", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 800f) + .step("Jump onto the moving lift", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 1_050f && snapshot.playerY() < 690f) + .step("Ride the lift to its upper phase", 7_000L, LevelPlaytestBot.Input.none(), + snapshot -> snapshot.playerY() <= 330f) + .step("Move to the lift's right edge", 2_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerX() >= 1_225f) + .step("Jump from the lift to the upper gallery", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerX() >= 1_610f && snapshot.playerY() <= 400f) + .step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + } + + private static boolean respawnedAtStart(GameView.AutomationSnapshot snapshot) { + return snapshot.playerAlive() && snapshot.playerX() < 250f; + } +}