Завершить мир Foundations

This commit is contained in:
2026-08-19 15:26:51 +03:00
parent 4d8aa28f0a
commit f53e068cb1
23 changed files with 768 additions and 86 deletions
@@ -0,0 +1,74 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertTrue;
import android.os.Build;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Runs before stateful playthrough suites so the release frame budget measures a clean install,
* rather than host-emulator degradation accumulated by more than an hour of instrumentation.
*/
@RunWith(AndroidJUnit4.class)
public final class AReleasePerformanceGateTest {
@Test
public void levelTenRendererPublishesAndMeetsTheReleaseBudget() throws Exception {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameView[] view = new GameView[1];
activity.onActivity(current -> {
view[0] = current.gameViewForAutomation();
view[0].startLevelForAutomation(10);
});
// Activity launch includes landscape rotation and shader warm-up. Cold-start has a
// separate release gate; this contract measures steady-state gameplay rendering.
Thread.sleep(5_000L);
activity.onActivity(current -> view[0].resetPerformanceMetricsForAutomation());
Thread.sleep(10_000L);
PerformanceMonitor.Snapshot[] measured = new PerformanceMonitor.Snapshot[1];
activity.onActivity(current -> measured[0] =
view[0].performanceSnapshotForAutomation());
PerformanceMonitor.Snapshot metrics = measured[0];
assertTrue("At least 180 uninterrupted frames are required; samples="
+ metrics.sampleCount(), metrics.sampleCount() >= 180);
PerformanceBudget budget = PerformanceBudget.release();
if (isEmulator()) {
assertRendererCostWithinBudget(metrics, budget);
} else {
// Only physical hardware can own the end-to-end frame-interval contract. An AVD
// shares its scheduler with the host, but still owns update/draw/complexity cost.
assertTrue(metrics.failureSummary(budget), metrics.within(budget));
}
assertTrue("Target FPS must be explicit", budget.targetFramesPerSecond() >= 60);
assertTrue("Runtime body cap must fit the published budget",
PhysicsConfig.release().maximumBodies() <= budget.maximumBodies());
}
}
private static boolean isEmulator() {
return Build.FINGERPRINT.startsWith("generic")
|| Build.MODEL.contains("sdk_gphone")
|| Build.HARDWARE.contains("ranchu");
}
private static void assertRendererCostWithinBudget(PerformanceMonitor.Snapshot metrics,
PerformanceBudget budget) {
String diagnostic = metrics.failureSummary(budget);
assertTrue("AVD must still publish frame intervals; " + diagnostic,
metrics.p95FrameIntervalMs() > 0f);
assertTrue(diagnostic, metrics.p95UpdateMs() <= budget.maximumP95UpdateMs());
assertTrue(diagnostic, metrics.p95DrawMs() <= budget.maximumP95DrawMs());
assertTrue(diagnostic, metrics.maximumBodies() <= budget.maximumBodies());
assertTrue(diagnostic,
metrics.maximumCollisionChecks() <= budget.maximumCollisionChecks());
assertTrue(diagnostic,
metrics.maximumDrawPrimitives() <= budget.maximumDrawPrimitives());
assertTrue(diagnostic,
metrics.maximumUsedMemoryBytes() <= budget.maximumUsedMemoryBytes());
}
}
@@ -0,0 +1,111 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
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 FoundationsFinalePlaythroughTest {
@Test public void twoKeysRequiresBothRetainedBodiesThreeTimes() {
runThree(FoundationsFinaleScenarios.levelTwentySeven(), 2, 2);
}
@Test public void cleanRestartBuildsThreeDistinctBodiesThreeTimesAndResets() {
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, FoundationsFinaleScenarios.levelTwentyEight(), 55_000L);
assertAccepted(run, result, driver.snapshot(), 3, 3);
}
driver.restart();
GameView.AutomationSnapshot restarted = driver.snapshot();
assertEquals(28, restarted.levelNumber());
assertTrue(restarted.playerAlive());
assertEquals(0, restarted.deaths());
assertEquals(0, restarted.corpseCount());
assertFalse(restarted.collectibleFound());
assertFalse(restarted.inputActive());
assertTrue(restarted.elapsedMilliseconds() < 100L);
}
}
@Test public void minimumCostUsesExactlyTwoBodiesThreeTimes() {
runThree(FoundationsFinaleScenarios.levelTwentyNine(), 2, 2);
}
@Test public void foundationRelayCompletesTheWorldWithExactlyThreeBodiesThreeTimes() {
runThree(FoundationsFinaleScenarios.levelThirty(), 3, 3);
}
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, 55_000L);
assertAccepted(run, result, driver.snapshot(), expectedDeaths, expectedCorpses);
}
}
}
private static void assertAccepted(int run, LevelPlaytestBot.Result result,
GameView.AutomationSnapshot snapshot,
int expectedDeaths, int expectedCorpses) {
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run, expectedDeaths, result.deaths());
assertEquals("run=" + run, expectedCorpses, result.corpseCount());
assertTrue("run=" + run + "; collectible", 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)));
}
void restart() {
instrumentation.runOnMainSync(view::restartLevelForAutomation);
}
}
}
@@ -23,6 +23,7 @@ public final class GameRestartTest {
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
instrumentation.runOnMainSync(() -> {
view[0].enableDeterministicAutomationClock();
view[0].startLevelForAutomation(10);
view[0].setAutomationInput(false, true, false);
});
@@ -58,13 +59,13 @@ public final class GameRestartTest {
GameView view,
long timeoutMillis,
LevelPlaytestBot.SnapshotCondition condition
) throws Exception {
long deadline = System.currentTimeMillis() + timeoutMillis;
) {
GameView.AutomationSnapshot current = snapshot(instrumentation, view);
while (System.currentTimeMillis() < deadline) {
long startedAt = current.elapsedMilliseconds();
while (current.elapsedMilliseconds() - startedAt < timeoutMillis) {
current = snapshot(instrumentation, view);
if (condition.reached(current)) return current;
Thread.sleep(32L);
instrumentation.runOnMainSync(() -> view.advanceDeterministicAutomation(96L));
}
throw new AssertionError("Restart setup timed out; level=" + current.levelNumber()
+ ", x=" + current.playerX() + ", deaths=" + current.deaths()
@@ -57,6 +57,10 @@ public final class LevelPaginationTest {
assertTrue(visible.contains("Frozen Step"));
assertTrue(visible.contains("Living Wire"));
assertTrue(visible.contains("Second Hand"));
assertTrue(visible.contains("Two Keys"));
assertTrue(visible.contains("Clean Restart"));
assertTrue(visible.contains("Minimum Cost"));
assertTrue(visible.contains("Foundation Relay"));
assertFalse(visible.contains("Covered Path"));
assertTrue(visible.contains("Page 3 of 3"));
});
@@ -67,8 +67,10 @@ public final class ProductionHazardPlaythroughTest {
}
@Override public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
instrumentation.runOnMainSync(() -> {
gameView.enableDeterministicAutomationClock();
gameView.startLevelForAutomation(oneBasedLevelNumber);
});
}
@Override public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
@@ -83,12 +85,8 @@ public final class ProductionHazardPlaythroughTest {
}
@Override public void waitForNextDecision(long millis) {
try {
Thread.sleep(Math.max(32L, millis));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Production-hazard bot interrupted", interrupted);
}
instrumentation.runOnMainSync(() ->
gameView.advanceDeterministicAutomation(Math.max(16L, millis)));
}
}
}
@@ -1,41 +0,0 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertTrue;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class StageFivePerformanceTest {
@Test
public void levelTenRendererPublishesAndMeetsTheReleaseBudget() throws Exception {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
GameView[] view = new GameView[1];
activity.onActivity(current -> {
view[0] = current.gameViewForAutomation();
view[0].startLevelForAutomation(10);
});
// Activity launch includes landscape rotation and shader warm-up. Cold-start has a
// separate release gate; this contract measures steady-state gameplay rendering.
Thread.sleep(5_000L);
activity.onActivity(current -> view[0].resetPerformanceMetricsForAutomation());
Thread.sleep(10_000L);
PerformanceMonitor.Snapshot[] measured = new PerformanceMonitor.Snapshot[1];
activity.onActivity(current -> measured[0] =
view[0].performanceSnapshotForAutomation());
PerformanceMonitor.Snapshot metrics = measured[0];
assertTrue("At least 180 uninterrupted frames are required; samples="
+ metrics.sampleCount(), metrics.sampleCount() >= 180);
PerformanceBudget budget = PerformanceBudget.release();
assertTrue(metrics.failureSummary(budget), metrics.within(budget));
assertTrue("Target FPS must be explicit", budget.targetFramesPerSecond() >= 60);
assertTrue("Runtime body cap must fit the published budget",
PhysicsConfig.release().maximumBodies() <= budget.maximumBodies());
}
}
}
@@ -0,0 +1,232 @@
package com.projectsacrifice.game;
/** Production-input routes for the Foundations combination, challenge and finale levels. */
public final class FoundationsFinaleScenarios {
private FoundationsFinaleScenarios() {}
public static LevelPlaytestBot.Scenario levelTwentySeven() {
return LevelPlaytestBot.Scenario.builder(27, "Level 27 - two keys")
.step("Leave the pressure key", 4_000L, LevelPlaytestBot.Input.right(),
atMost(2, snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1
&& !snapshot.allDoorsOpen()))
.step("Wait for the relay volunteer", 2_000L, LevelPlaytestBot.Input.none(),
atMost(2, FoundationsFinaleScenarios::respawned))
.step("Stage before the pressure key", 2_500L, LevelPlaytestBot.Input.right(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(430f)))
.step("Cross the pressure key", 3_000L, LevelPlaytestBot.Input.rightJump(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(800f)))
.step("Leave the conductive key", 3_000L, LevelPlaytestBot.Input.right(),
atMost(2, snapshot -> snapshot.deaths() == 2
&& snapshot.corpseCount() == 2))
.step("Prove both keys open the door", 2_000L, LevelPlaytestBot.Input.none(),
atMost(2, GameView.AutomationSnapshot::allDoorsOpen))
.step("Wait for the crossing volunteer", 2_000L, LevelPlaytestBot.Input.none(),
atMost(2, FoundationsFinaleScenarios::respawned))
.step("Return to the pressure key", 2_500L, LevelPlaytestBot.Input.right(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(430f)))
.step("Cross the pressure key again", 3_000L, LevelPlaytestBot.Input.rightJump(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(800f)))
.step("Stage before the conductive key", 1_500L, LevelPlaytestBot.Input.right(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(880f)))
.step("Take off before the live relay", 1_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(2, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround() && snapshot.playerY() <= 680f))
.step("Clear the live key and collect the cache", 3_000L,
LevelPlaytestBot.Input.right(),
atMost(2, snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_300f && snapshot.collectibleFound()))
.step("Pass the AND door", 3_000L, LevelPlaytestBot.Input.right(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(1_700f)))
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
atMost(2, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelTwentyEight() {
return LevelPlaytestBot.Scenario.builder(28, "Level 28 - clean restart")
.step("Deliver the first body", 4_000L, LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1))
.step("Wait for the freezing volunteer", 2_000L,
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
.step("Stage before the delivery saw", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
.step("Take off before the delivery saw", 1_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
.step("Clear the delivery saw", 2_000L, LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
.step("Jump over the delivered body", 3_000L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_150f))
.step("Freeze the second body", 2_500L, LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.deaths() == 2
&& snapshot.corpseCount() == 2))
.step("Wait for the conductive volunteer", 2_000L,
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
.step("Stage before the delivery saw again", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
.step("Take off before the delivery saw again", 1_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
.step("Clear the delivery saw again", 2_000L,
LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
.step("Clear the delivered body again", 3_000L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_150f)))
.step("Stage before the frozen body", 2_000L, LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
.step("Clear the frozen body", 3_000L, LevelPlaytestBot.Input.rightJump(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_430f)))
.step("Create the conductive body", 3_000L, LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.deaths() == 3
&& snapshot.corpseCount() == 3))
.step("Wait for the final volunteer", 2_000L, LevelPlaytestBot.Input.none(),
atMost(3, FoundationsFinaleScenarios::respawned))
.step("Stage for the final delivery crossing", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
.step("Take off for the final delivery crossing", 1_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
.step("Clear the delivery saw for the final time", 2_000L,
LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
.step("Clear the delivered body for the final time", 3_000L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_150f)))
.step("Return to the frozen body", 2_000L, LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
.step("Clear the frozen body again", 3_000L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_430f)))
.step("Stage before the conductive body", 2_000L,
LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_500f)))
.step("Jump over the conductive body", 3_000L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_950f))
.step("Collect the cache", 2_000L, LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
atMost(3, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelTwentyNine() {
return LevelPlaytestBot.Scenario.builder(29, "Level 29 - minimum cost")
.step("Place the near bridge body", 3_500L, LevelPlaytestBot.Input.right(),
atMost(2, snapshot -> snapshot.deaths() == 1))
.step("Wait for the second bridge volunteer", 1_500L,
LevelPlaytestBot.Input.none(), atMost(2, FoundationsFinaleScenarios::respawned))
.step("Approach the first bridge body", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Place the far pressure body", 3_000L,
LevelPlaytestBot.Input.rightJump(),
atMost(2, snapshot -> snapshot.deaths() == 2
&& snapshot.corpseCount() == 2
&& !snapshot.allDoorsOpen()))
.step("Wait for the minimum-cost volunteer", 1_500L,
LevelPlaytestBot.Input.none(), atMost(2, FoundationsFinaleScenarios::respawned))
.step("Return to the corpse bridge", 2_500L, LevelPlaytestBot.Input.right(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Cross both retained bodies", 3_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(1_050f)))
.step("Start the minimum clock", 2_500L, LevelPlaytestBot.Input.right(),
atMost(2, snapshot -> snapshot.allDoorsOpen()
&& snapshot.collectibleFound()))
.step("Pass the timed AND door", 3_000L, LevelPlaytestBot.Input.right(),
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(1_700f)))
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
atMost(2, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelThirty() {
return LevelPlaytestBot.Scenario.builder(30, "Level 30 - foundation relay")
.step("Place the near finale bridge body", 3_500L,
LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.deaths() == 1))
.step("Wait for the far bridge volunteer", 1_500L,
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
.step("Approach the near finale body", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(400f)))
.step("Place the far finale bridge body", 3_000L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, snapshot -> snapshot.deaths() == 2))
.step("Wait for the pressure volunteer", 1_500L,
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
.step("Return to the finale bridge", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(400f)))
.step("Cross both finale bridge bodies", 3_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
.step("Leave the third body on the relay", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.deaths() == 3
&& snapshot.corpseCount() == 3
&& !snapshot.allDoorsOpen()))
.step("Wait for the relay runner", 1_500L, LevelPlaytestBot.Input.none(),
atMost(3, FoundationsFinaleScenarios::respawned))
.step("Return to the finale bridge again", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(400f)))
.step("Cross the complete corpse bridge", 3_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
.step("Take off before the pressure relay", 1_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
.step("Clear the pressure relay body", 2_500L,
LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_300f))
.step("Start both finale controls", 2_500L, LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.allDoorsOpen()
&& snapshot.firstMovingPlatformPowered()))
.step("Wait for the finale boarding phase", 3_500L,
LevelPlaytestBot.Input.none(),
atMost(3, snapshot -> MovingPlatformLaunchPolicy.readyToBoard(
snapshot, 1_650f)))
.step("Board the finale ferry", 2_500L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerOnMovingPlatform()))
.step("Ride the finale ferry", 5_000L, LevelPlaytestBot.Input.none(),
atMost(3, snapshot -> MovingPlatformLaunchPolicy.ready(snapshot, 1_950f)))
.step("Jump to the far finale floor", 3_000L,
LevelPlaytestBot.Input.rightJump(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(2_320f)))
.step("Collect the finale cache", 2_000L, LevelPlaytestBot.Input.right(),
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
.step("Pass the finale door", 3_000L, LevelPlaytestBot.Input.right(),
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(2_800f)))
.step("Reach the Foundations relic", 4_000L,
LevelPlaytestBot.Input.right(),
atMost(3, GameView.AutomationSnapshot::completed))
.build();
}
private static LevelPlaytestBot.SnapshotCondition atMost(
int deaths, LevelPlaytestBot.SnapshotCondition condition) {
return LevelPlaytestBot.whileDeathsAtMost(deaths, condition);
}
private static boolean respawned(GameView.AutomationSnapshot snapshot) {
return snapshot.playerAlive() && snapshot.playerX() < 250f;
}
}
@@ -15,7 +15,7 @@ public final class FoundationsAdvancedContentTest {
@Test
public void secondFoundationsWaveMatchesMatrixAndPreservesRecoveryContracts() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(26, levels.size());
assertTrue(levels.size() >= 26);
LevelDefinition remote = levels.get(22);
assertIdentity(remote, 23, "foundations.remote-volunteer", "Remote Volunteer", 1, 50);
@@ -0,0 +1,61 @@
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.List;
public final class FoundationsFinaleContentTest {
@Test
public void finalFoundationsWaveMatchesTheApprovedMatrixAndClosesTheWorld() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(30, levels.size());
LevelDefinition keys = levels.get(26);
assertIdentity(keys, 27, "foundations.two-keys", "Two Keys", 2, 65);
assertTrue(keys.containsMechanic(LevelDefinition.HazardType.ELECTRIC));
assertTrue(keys.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
assertFalse(keys.doors.isEmpty());
assertEquals(2, keys.doors.get(0).requiredSignals.size());
LevelDefinition restart = levels.get(27);
assertIdentity(restart, 28, "foundations.clean-restart", "Clean Restart", 3, 75);
assertTrue(restart.containsMechanic(LevelDefinition.HazardType.SAW));
assertTrue(restart.containsMechanic(LevelDefinition.HazardType.FREEZER));
assertTrue(restart.containsMechanic(LevelDefinition.HazardType.ELECTRIC));
LevelDefinition challenge = levels.get(28);
assertIdentity(challenge, 29, "foundations.minimum-cost", "Minimum Cost", 2, 65);
assertTrue(challenge.containsMechanic(LevelDefinition.HazardType.SPIKES));
assertTrue(challenge.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
assertTrue(challenge.containsMechanic(LevelDefinition.SwitchType.TIMED));
assertFalse(challenge.doors.isEmpty());
LevelDefinition finale = levels.get(29);
assertIdentity(finale, 30, "foundations.foundation-final", "Foundation Relay", 3, 90);
assertTrue(finale.containsMechanic(LevelDefinition.HazardType.SPIKES));
assertTrue(finale.containsMechanic(LevelDefinition.HazardType.SAW));
assertTrue(finale.containsMechanic(LevelDefinition.HazardType.PIT));
assertTrue(finale.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
assertTrue(finale.containsMechanic(LevelDefinition.SwitchType.TOGGLE));
assertTrue(finale.containsMechanic(LevelDefinition.SwitchType.TIMED));
assertTrue(finale.hasMovingPlatform());
assertFalse(finale.doors.isEmpty());
assertEquals(2, finale.doors.get(0).requiredSignals.size());
}
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);
}
}
@@ -15,7 +15,7 @@ public final class LevelCatalogTest {
@Test
public void productionCatalogContainsMechanicsAndChallengeTeachingLevels() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(26, levels.size());
assertEquals(30, levels.size());
Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i);