Добавить вторую волну мира Foundations

This commit is contained in:
2026-08-19 11:25:52 +03:00
parent b31c2ea720
commit 4d8aa28f0a
19 changed files with 556 additions and 25 deletions
@@ -0,0 +1,88 @@
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;
@RunWith(AndroidJUnit4.class)
public final class FoundationsAdvancedPlaythroughTest {
@Test public void remoteVolunteerUsesOneDeliveredBodyThreeTimes() {
runThree(AdvancedFoundationsScenarios.levelTwentyThree(), 1, 1);
}
@Test public void frozenStepUsesOneFrozenBodyThreeTimes() {
runThree(AdvancedFoundationsScenarios.levelTwentyFour(), 1, 1);
}
@Test public void livingWireUsesOneConductiveBodyThreeTimes() {
runThree(AdvancedFoundationsScenarios.levelTwentyFive(), 1, 1);
}
@Test public void secondHandCompletesOnTheOriginalTimerThreeTimes() {
runThree(AdvancedFoundationsScenarios.levelTwentySixIntended(), 0, 0);
}
@Test public void secondHandRecoversAfterTheFarGateClosesThreeTimes() {
runThree(AdvancedFoundationsScenarios.levelTwentySixRecovery(), 0, 0);
}
private static void runThree(LevelPlaytestBot.Scenario scenario,
int expectedDeaths, int expectedCorpses) {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
Driver driver = Driver.from(activity);
for (int run = 1; run <= 3; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 45_000L);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run, expectedDeaths, result.deaths());
assertEquals("run=" + run, expectedCorpses, result.corpseCount());
assertTrue("run=" + run + "; collectible", driver.snapshot().collectibleFound());
}
}
}
private static final class Driver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView view;
private Driver(Instrumentation instrumentation, GameView view) {
this.instrumentation = instrumentation;
this.view = view;
}
static Driver from(ActivityScenario<MainActivity> activity) {
GameView[] target = new GameView[1];
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
}
@Override public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(() -> {
view.enableDeterministicAutomationClock();
view.startLevelForAutomation(oneBasedLevelNumber);
});
}
@Override public void setInput(boolean left, boolean right, boolean jump) {
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
}
@Override public GameView.AutomationSnapshot snapshot() {
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
return value[0];
}
@Override public void waitForNextDecision(long millis) {
instrumentation.runOnMainSync(() ->
view.advanceDeterministicAutomation(Math.max(16L, millis)));
}
}
}
@@ -53,6 +53,10 @@ public final class LevelPaginationTest {
assertEquals(2, view.levelPageIndexForAutomation());
assertTrue(visible.contains("Held Open"));
assertTrue(visible.contains("Clockwork Step"));
assertTrue(visible.contains("Remote Volunteer"));
assertTrue(visible.contains("Frozen Step"));
assertTrue(visible.contains("Living Wire"));
assertTrue(visible.contains("Second Hand"));
assertFalse(visible.contains("Covered Path"));
assertTrue(visible.contains("Page 3 of 3"));
});
@@ -0,0 +1,172 @@
package com.projectsacrifice.game;
/** Production-input routes for Foundations levels 23-26. */
public final class AdvancedFoundationsScenarios {
private AdvancedFoundationsScenarios() {}
public static LevelPlaytestBot.Scenario levelTwentyThree() {
return LevelPlaytestBot.Scenario.builder(23, "Level 23 - remote volunteer")
.step("Launch one volunteer onto the remote plate", 4_000L,
LevelPlaytestBot.Input.right(),
atMost(1, snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1))
.step("Observe the delivered body open the door", 2_500L,
LevelPlaytestBot.Input.none(),
atMost(1, GameView.AutomationSnapshot::allDoorsOpen))
.step("Wait for the crossing volunteer", 2_000L,
LevelPlaytestBot.Input.none(), atMost(1, AdvancedFoundationsScenarios::respawned))
.step("Stage before the launch saw", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
.step("Take off before the launch saw", 1_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(1, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
.step("Clear the launch saw", 2_000L, LevelPlaytestBot.Input.right(),
atMost(1, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
.step("Approach the delivered body", 1_500L, LevelPlaytestBot.Input.right(),
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(780f)))
.step("Jump over the body and collect the cache", 3_000L,
LevelPlaytestBot.Input.rightJump(),
atMost(1, snapshot -> snapshot.playerAlive()
&& snapshot.collectibleFound()))
.step("Pass the remote gate", 3_000L, LevelPlaytestBot.Input.right(),
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_650f)))
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
atMost(1, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelTwentyFour() {
return LevelPlaytestBot.Scenario.builder(24, "Level 24 - frozen step")
.step("Freeze one volunteer", 4_000L, LevelPlaytestBot.Input.right(),
atMost(1, snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1))
.step("Wait for the climbing volunteer", 2_000L,
LevelPlaytestBot.Input.none(), atMost(1, AdvancedFoundationsScenarios::respawned))
.step("Approach the frozen block", 2_500L, LevelPlaytestBot.Input.right(),
atMost(1, snapshot -> snapshot.playerX() >= 420f))
.step("Jump toward the frozen block", 1_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(1, snapshot -> snapshot.playerX() >= 670f))
.step("Settle on the frozen block", 2_000L, LevelPlaytestBot.Input.none(),
atMost(1, snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerY() >= 668f && snapshot.playerY() <= 690f))
.step("Jump to the upper route", 3_000L, LevelPlaytestBot.Input.rightJump(),
atMost(1, snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerX() >= 850f && snapshot.playerY() <= 500f))
.step("Cross the ledge and collect the cache", 3_000L,
LevelPlaytestBot.Input.right(),
atMost(1, snapshot -> snapshot.playerAlive()
&& snapshot.collectibleFound() && snapshot.playerX() >= 1_300f))
.step("Jump to the final ledge", 2_500L, LevelPlaytestBot.Input.rightJump(),
atMost(1, snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_480f && snapshot.playerY() <= 520f))
.step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(),
atMost(1, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelTwentyFive() {
return LevelPlaytestBot.Scenario.builder(25, "Level 25 - living wire")
.step("Power the relay with one volunteer", 4_000L,
LevelPlaytestBot.Input.right(),
atMost(1, snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1))
.step("Observe the conductive body open the door", 2_000L,
LevelPlaytestBot.Input.none(),
atMost(1, GameView.AutomationSnapshot::allDoorsOpen))
.step("Wait for the crossing volunteer", 2_000L,
LevelPlaytestBot.Input.none(), atMost(1, AdvancedFoundationsScenarios::respawned))
.step("Stage before the live relay", 2_500L, LevelPlaytestBot.Input.right(),
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Take off before the live relay", 1_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(1, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround() && snapshot.playerY() <= 680f))
.step("Clear the conductive body and collect the cache", 3_000L,
LevelPlaytestBot.Input.right(),
atMost(1, snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_080f && snapshot.collectibleFound()))
.step("Pass the powered door", 3_000L, LevelPlaytestBot.Input.right(),
atMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_470f)))
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
atMost(1, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelTwentySixIntended() {
return timedRoute(false);
}
public static LevelPlaytestBot.Scenario levelTwentySixRecovery() {
return timedRoute(true);
}
private static LevelPlaytestBot.Scenario timedRoute(boolean recovery) {
LevelPlaytestBot.Scenario.Builder route = LevelPlaytestBot.Scenario.builder(26,
recovery ? "Level 26 - far-side recovery" : "Level 26 - original timer");
route.step("Start the second-hand timer", 3_000L, LevelPlaytestBot.Input.right(),
noDeaths(snapshot -> snapshot.allDoorsOpen()
&& snapshot.firstMovingPlatformPowered()))
.step("Approach the timed ferry", 2_000L, LevelPlaytestBot.Input.right(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(800f)))
.step("Wait for the outbound boarding phase", 3_500L,
LevelPlaytestBot.Input.none(),
noDeaths(snapshot -> MovingPlatformLaunchPolicy.readyToBoard(snapshot, 1_000f)))
.step("Board the timed ferry", 2_500L, LevelPlaytestBot.Input.rightJump(),
noDeaths(snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerOnMovingPlatform()))
.step("Ride to the far launch phase", 4_000L, LevelPlaytestBot.Input.none(),
noDeaths(snapshot -> MovingPlatformLaunchPolicy.ready(snapshot, 1_250f)))
.step("Jump to the far floor", 3_000L, LevelPlaytestBot.Input.rightJump(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_600f)));
if (recovery) {
route.step("Move beneath the high recovery relay", 2_000L,
LevelPlaytestBot.Input.right(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_650f)))
.step("Wait for the far gate to close", 10_000L,
LevelPlaytestBot.Input.none(),
noDeaths(snapshot -> !snapshot.allDoorsOpen()))
.step("Jump into the recovery relay", 2_500L,
LevelPlaytestBot.Input.rightJump(),
noDeaths(GameView.AutomationSnapshot::allDoorsOpen));
} else {
route.step("Pass the door on the original timer", 3_000L,
LevelPlaytestBot.Input.right(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(2_030f)));
}
return route.step("Stage before the upper route", 2_000L,
LevelPlaytestBot.Input.right(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(2_050f)))
.step("Jump onto the upper route", 3_000L,
LevelPlaytestBot.Input.rightJump(),
noDeaths(snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerX() >= 2_250f
&& snapshot.playerY() <= 610f))
.step("Collect the upper cache", 2_000L,
LevelPlaytestBot.Input.right(),
noDeaths(snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
.step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(),
noDeaths(GameView.AutomationSnapshot::completed))
.build();
}
private static LevelPlaytestBot.SnapshotCondition atMost(
int deaths, LevelPlaytestBot.SnapshotCondition condition) {
return LevelPlaytestBot.whileDeathsAtMost(deaths, condition);
}
private static LevelPlaytestBot.SnapshotCondition noDeaths(
LevelPlaytestBot.SnapshotCondition condition) {
return atMost(0, condition);
}
private static boolean respawned(GameView.AutomationSnapshot snapshot) {
return snapshot.playerAlive() && snapshot.playerX() < 250f;
}
}
@@ -0,0 +1,58 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public final class FoundationsAdvancedContentTest {
@Test
public void secondFoundationsWaveMatchesMatrixAndPreservesRecoveryContracts() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(26, levels.size());
LevelDefinition remote = levels.get(22);
assertIdentity(remote, 23, "foundations.remote-volunteer", "Remote Volunteer", 1, 50);
assertTrue(remote.containsMechanic(LevelDefinition.HazardType.SAW));
assertTrue(remote.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
assertFalse(remote.doors.isEmpty());
LevelDefinition frozen = levels.get(23);
assertIdentity(frozen, 24, "foundations.frozen-step", "Frozen Step", 1, 48);
assertTrue(frozen.containsMechanic(LevelDefinition.HazardType.FREEZER));
assertTrue(frozen.goal.y < 700f);
LevelDefinition live = levels.get(24);
assertIdentity(live, 25, "foundations.living-wire", "Living Wire", 1, 52);
assertTrue(live.containsMechanic(LevelDefinition.HazardType.ELECTRIC));
assertFalse(live.doors.isEmpty());
LevelDefinition timed = levels.get(25);
assertIdentity(timed, 26, "foundations.borrowed-time", "Second Hand", 0, 55);
assertTrue(timed.containsMechanic(LevelDefinition.SwitchType.TIMED));
assertTrue(timed.containsMechanic(LevelDefinition.HazardType.PIT));
assertTrue(timed.hasMovingPlatform());
assertFalse(timed.doors.isEmpty());
assertEquals(2, timed.switches.size());
Set<String> signals = new HashSet<>();
for (LevelDefinition.Switch item : timed.switches) signals.add(item.signalId);
assertEquals(Set.of(timed.platforms.get(3).controlId), signals);
}
private static void assertIdentity(LevelDefinition level, int number, String id, String title,
int targetDeaths, int targetSeconds) {
assertEquals(number, level.number);
assertEquals(id, level.levelId);
assertEquals("foundations", level.worldId);
assertEquals(title, level.title);
assertEquals(targetDeaths, level.targetDeaths);
assertEquals(targetSeconds, level.targetSeconds);
assertNotNull(level.collectible);
}
}
@@ -13,7 +13,7 @@ public final class FoundationsContentTest {
@Test
public void firstFoundationsWaveMatchesTheApprovedMatrixAndTeachesOneIdeaAtATime() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(22, levels.size());
assertTrue(levels.size() >= 22);
LevelDefinition firstReturn = levels.get(18);
assertIdentity(firstReturn, 19, "foundations.first-return", "First Return", 0, 28);
@@ -15,7 +15,7 @@ public final class LevelCatalogTest {
@Test
public void productionCatalogContainsMechanicsAndChallengeTeachingLevels() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(22, levels.size());
assertEquals(26, levels.size());
Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i);