Compare commits

...
56 changed files with 3057 additions and 73 deletions
+1 -1
View File
@@ -67,7 +67,7 @@
{"_type":"issue","id":"goon-game-analytics-contract","title":"Схема gameplay analytics","description":"Версионировать level_start/restart/death/complete/abandon/challenge и heatmap payload.","acceptance_criteria":"События документированы, минимальны и проверены на тестовом backend.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:36Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:35Z","metadata":{"stages":7,"primary_stage":7},"labels":["analytics","primary-stage-7","production","stage-7"],"dependencies":[{"issue_id":"goon-game-analytics-contract","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-modular-characters","title":"Модульные персонажи и косметика","description":"Body/helmet/face/weapon/cape/accessory, случайные имена и 30+ косметических элементов.","acceptance_criteria":"Новые жертвы визуально различаются, комбинации не ломают анимации.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2880,"created_at":"2026-08-17T21:40:21Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["art","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-modular-characters","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-challenges","title":"Challenges, collectibles и статистика","description":"Реализовать casualty/time/collectible цели и отображение трёх звёзд.","acceptance_criteria":"Расчёт покрыт unit-тестами и сохраняется по LevelID.","notes":"2026-08-19: claimed after weighted-object slice 259fcd3 passed 108 app JVM + 2 content-tool JVM and exact Android 48/48. TDD scope: stable LevelID/WorldID progression keys with legacy numeric migration, casualty/time/collectible challenge evaluation, persistent best three-star record, data-driven optional collectible, level-select/result rendering, automation and one teaching route without weakening existing gates.\nFinal post-review gate: duplicate stable LevelID now fails catalog load before progress-key collision (targeted RED then GREEN). Clean 116 app JVM + 2 content-tool JVM; final cold host-GPU AVD exact Android 52/52 in 551.773s. Verified APK SHA-256 0b70a0a1a508425c311d7de473318e3bb83407f5f27e9dd4a8e1876b2eb25344.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T06:24:46Z","started_at":"2026-08-19T04:41:26Z","closed_at":"2026-08-19T05:58:26Z","close_reason":"Accepted stable LevelID/WorldID challenge progression: progress v3/session v5 migrations, casualty/time/collectible stars, level 18 3/3 route, 115+2 JVM and exact Android 52/52 (553.686s), APK SHA-256 9c12cfbb15e313421d58f891d9d107af14c9e8e626228fa8fd0c74e56c293d14.","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","progression","stage-6"],"dependencies":[{"issue_id":"goon-game-challenges","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","notes":"2026-08-19: executable matrix commit 4869649 fixed release at 69 levels: workshop 1-17 plus four 13-level production worlds (52 rows). Foundations wave 1 now authors Levels 19-22; matrix prefix is 5/52 including Level 18. TDD/device REDs fixed only in new content: inaccessible L19 cache, skipped second L20 body, late L21 saw takeoff, early-moving L22 ferry; pagination acceptance updated from 2 to 3 pages. Four route methods passed 12/12 playthroughs, restart DoD passed 1/1. Clean gate: 117/117 app JVM, 7/7 level-format, validators 22/authored5, lint/app/test APK; final cold unfiltered Android 57/57 in 1807.264s, APK SHA 81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef. Next bounded slice is Foundations 23-26; issue remains in_progress until Level 69.\n2026-08-19: Foundations wave 2 authors Levels 23-26; matrix prefix is 9/52. Remote saw delivery, frozen-body step and conductive relay pass exact 1 death/1 corpse. Level 26 Second Hand passes original-timer and expired-timer recovery routes, both 3/3 with zero deaths; recovery explicitly observes the far Door closed before rearming the shared signal. Focused suite: 5/5 methods, 15 routes, 10.768s. Clean gate: 118/118 app JVM, 7/7 level-format, validators 26/authored9, lint/app/test APK; final cold unfiltered Android 62/62 in 1063.114s, APK SHA 67510d404cfa0704500cbf36c7845e9ba9fa05e96630079ab02bc35113e946fa. Next bounded slice is Foundations 27-30; issue remains in_progress until Level 69.\n2026-08-19: Foundations wave 3 authors Levels 27-30 and closes the world at 13/13 matrix rows (authored production prefix 13/52). Level 27 proves two retained-body AND; Level 28 proves delivered/frozen/conductive exact 3-body route plus clean restart; Level 29 CHALLENGE proves exact two bodies; Level 30 FINALE combines bridge, pressure, toggle/timed ferry, PIT and Door at exact 3 bodies. Focused suite: 4/4 methods, 12 routes, 35.188s. Late-load REDs led only to deterministic test-clock hardening and an AVD-specific performance evidence boundary; physical hardware retains full 34ms frame gate. Final clean gate: 119/119 app JVM, 7/7 level-format, validators 30/authored13, lint/app/test APK; cold unfiltered Android 66/66 in 6749.094s, APK SHA d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315. Next bounded slice is Physics 31-34; issue remains in_progress until Level 69.\n2026-08-19: Physics wave 1 authors Levels 31-34; authored production prefix is 17/52. Weight Class proves mass-3 object pressure with clean restart; Stack Order adds deterministic falling object-on-object support; Launch Window proves exact one saw body plus vertical lift; Press Cycle proves exact one crushed retained body plus active/rest crusher timing. Focused suite: 4/4 methods, 12 routes; first run 10.003s, restart-proof rerun green. TDD REDs were missing PhysicalObjectStackResolver and PhysicsWaveOneScenarios. Clean gate: 122/122 app JVM, 7/7 level-format, validators 34/authored17, lint/app/test APK; cold unfiltered Android 70/70 in 611.991s, APK SHA 1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3. Next bounded slice is Physics 35-38; issue remains in_progress until Level 69.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T12:52:14Z","started_at":"2026-08-19T06:26:20Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","notes":"2026-08-19: claimed after corpse-state slice 19bb64e passed 102 JVM and exact Android 46/46. TDD scope: public IWeightedObject mass contract, JSON Box/Rock/Ball/IceBlock/Counterweight definitions, deterministic runtime/save/render/automation, pressure parity and one teaching level with solution bot.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T04:38:25Z","started_at":"2026-08-19T03:35:44Z","closed_at":"2026-08-19T04:38:25Z","close_reason":"Shared pressure-mass contract, five authored physical-object types, deterministic runtime/save v4/render/export, level 17 and production-input route accepted. Clean 108 app JVM + 2 format JVM; focused 8/8; exact unfiltered Android 16 matrix 48/48 in 2223.695s.","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","notes":"2026-08-19: claimed after the three Stage 6 foundation slices. Will proceed test-first per hazard with data definition, deterministic behavior, teaching content, solution graph and production-input route; crusher/fire precede the dependent crushed/burned corpse states.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T02:28:37Z","started_at":"2026-08-19T01:07:11Z","closed_at":"2026-08-19T02:28:37Z","close_reason":"Crusher, fire, projectile and cyclic explosion are data-driven, validated and shipped with teaching levels 11-14, solution graphs and 3x production-input routes; clean 99 JVM/lint/APK and unfiltered Android 43/43 green.","metadata":{"stages":6,"primary_stage":6},"labels":["hazards","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-hazards-full","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:10Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","notes":"2026-08-19: claimed after production hazards dc2ebcb passed 99 JVM and Android 43/43. Will add explicit crushed/burned/shattered and derived corpse behavior test-first, then one teaching route/solution graph per new gameplay-relevant state and full regression.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:49Z","started_at":"2026-08-19T02:31:25Z","closed_at":"2026-08-19T03:32:49Z","close_reason":"GDD corpse states complete: existing normal/pinned/frozen/conductive routes plus new burned/crushed contracts, save v3 migration, levels 15-16 and bots. Clean 102 JVM; exact Android 46/46 in 1842.248s. IsDestroyed is a lifecycle field; Shattered is not an approved GDD state.","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-hazards-full","type":"blocks","created_at":"2026-08-19T04:07:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
+27 -4
View File
@@ -125,15 +125,38 @@ Burned/crushed corpse states are taught in levels 15–16, level 17 adds five
authored physical-object types over one shared pressure-mass contract, and level
18 teaches the casualty/time/cache challenge contract with persistent three-star
progress keyed by stable `levelId`.
The first bounded Foundations authoring wave adds levels 19–22: a two-jump
movement route, a two-body spike bridge, a retained pressure-door body and a
toggle-controlled horizontal ferry. All four routes pass three consecutive runs,
and restart clears the stateful bridge/ferry setups.
The second wave adds levels 23–26: remote saw delivery, a frozen-body step, a
conductive-body relay and a timed ferry with an explicit far-side recovery relay.
Five route methods prove all four levels three times, including both the original
timer and expired-timer recovery routes for level 26.
The final Foundations wave adds levels 27–30: a two-body AND gate, a three-state
body sequence with clean restart, an exact-two-death challenge and a finale that
combines corpse bridge, pressure, toggle/timer ferry and Door. The full
Foundations world now occupies its accepted 18–30 matrix range.
The first Physics wave adds levels 31–34: object-held mass, a real three-object
support stack, a saw-delivery lift route and a crusher/pressure cycle. Vertical
object-on-object support is deterministic and covered independently from player
movement; all four routes pass three consecutive production-input runs.
Scope and final evidence are in `docs/STAGE6_CHARACTER_STATES.md`,
`docs/STAGE6_CORPSE_MANAGER.md`, and `docs/STAGE6_PUZZLE_SYSTEM.md`; the latest
hazard evidence is in `docs/STAGE6_PRODUCTION_HAZARDS.md`, and the burned/crushed
contract is in `docs/STAGE6_CORPSE_STATES.md`. Weighted-object evidence is in
`docs/STAGE6_WEIGHTED_OBJECTS.md`; challenge and migration evidence is in
`docs/STAGE6_CHALLENGES.md`. The latest exact gate is `116 app JVM + 2
content-tool JVM / 52 Android` on the eighteen-level candidate (`OK (52 tests)`,
551.773 seconds). The verified debug APK SHA-256 is
`0b70a0a1a508425c311d7de473318e3bb83407f5f27e9dd4a8e1876b2eb25344`.
`docs/STAGE6_CHALLENGES.md`. The executable content matrix fixes the release at
69 levels: 17 accepted workshop levels plus 52 production slots in Foundations,
Physics, States and Mastery. Its contract and Level DoD pipeline are documented
in `docs/STAGE6_CONTENT_MATRIX.md`; wave evidence is in
`docs/STAGE6_FOUNDATIONS_WAVE1.md` and
`docs/STAGE6_FOUNDATIONS_WAVE2.md`; the completed world evidence is in
`docs/STAGE6_FOUNDATIONS_WAVE3.md`. Physics evidence starts in
`docs/STAGE6_PHYSICS_WAVE1.md`. The latest exact gate is `122 app JVM + 7
content-tool JVM / 70 Android` on the thirty-four-level candidate (`70/70`,
611.991 seconds). The verified debug APK SHA-256 is
`1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3`.
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with:
+1
View File
@@ -47,4 +47,5 @@ dependencies {
tasks.named('preBuild').configure {
dependsOn ':level-format:validateLevels'
dependsOn ':level-format:validateContentMatrix'
}
@@ -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,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)));
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,84 @@
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 FoundationsPlaythroughTest {
@Test public void firstReturnCompletesDeathlessThreeTimes() {
runThree(FoundationsScenarios.levelNineteen(), 0, 0);
}
@Test public void coveredPathUsesExactlyTwoBodiesThreeTimes() {
runThree(FoundationsScenarios.levelTwenty(), 2, 2);
}
@Test public void heldOpenUsesExactlyOnePlateBodyThreeTimes() {
runThree(FoundationsScenarios.levelTwentyOne(), 1, 1);
}
@Test public void clockworkStepCompletesDeathlessThreeTimes() {
runThree(FoundationsScenarios.levelTwentyTwo(), 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, 30_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)));
}
}
}
@@ -0,0 +1,96 @@
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 FoundationsRestartTest {
@Test
public void restartClearsCoveredPathBodiesAndClockworkFerryState() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
GameView[] target = new GameView[1];
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
GameView view = target[0];
start(instrumentation, view, 20);
input(instrumentation, view, false, true, false);
GameView.AutomationSnapshot bridge = advanceUntil(instrumentation, view,
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1);
assertEquals(1, bridge.deaths());
instrumentation.runOnMainSync(() -> {
view.setAutomationInput(false, true, true);
view.restartLevelForAutomation();
});
assertCleanRestart(snapshot(instrumentation, view), 20);
start(instrumentation, view, 22);
input(instrumentation, view, false, true, false);
GameView.AutomationSnapshot ferry = advanceUntil(instrumentation, view,
GameView.AutomationSnapshot::firstMovingPlatformPowered);
assertTrue(ferry.firstMovingPlatformPowered());
instrumentation.runOnMainSync(() -> {
view.setAutomationInput(false, true, true);
view.restartLevelForAutomation();
});
GameView.AutomationSnapshot restartedFerry = snapshot(instrumentation, view);
assertCleanRestart(restartedFerry, 22);
assertFalse(restartedFerry.firstMovingPlatformPowered());
assertEquals(940f, restartedFerry.firstMovingPlatformX(), 0.5f);
}
}
private static void assertCleanRestart(GameView.AutomationSnapshot snapshot, int level) {
assertEquals(level, snapshot.levelNumber());
assertTrue(snapshot.playerAlive());
assertEquals(0, snapshot.deaths());
assertEquals(0, snapshot.corpseCount());
assertFalse(snapshot.collectibleFound());
assertFalse(snapshot.inputActive());
assertTrue(snapshot.elapsedMilliseconds() < 100L);
assertTrue(snapshot.playerX() >= 125f && snapshot.playerX() <= 135f);
}
private static void start(Instrumentation instrumentation, GameView view, int level) {
instrumentation.runOnMainSync(() -> {
view.enableDeterministicAutomationClock();
view.startLevelForAutomation(level);
});
}
private static void input(Instrumentation instrumentation, GameView view,
boolean left, boolean right, boolean jump) {
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
}
private static GameView.AutomationSnapshot advanceUntil(
Instrumentation instrumentation, GameView view,
LevelPlaytestBot.SnapshotCondition condition) {
GameView.AutomationSnapshot current = snapshot(instrumentation, view);
for (int tick = 0; tick < 100; tick++) {
if (condition.reached(current)) return current;
instrumentation.runOnMainSync(() -> view.advanceDeterministicAutomation(96L));
current = snapshot(instrumentation, view);
}
throw new AssertionError("Foundations restart setup timed out; level="
+ current.levelNumber() + ", x=" + current.playerX()
+ ", deaths=" + current.deaths() + ", corpses=" + current.corpseCount());
}
private static GameView.AutomationSnapshot snapshot(
Instrumentation instrumentation, GameView view) {
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
return value[0];
}
}
@@ -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()
@@ -24,7 +24,7 @@ public final class LevelPaginationTest {
GameView view = current.gameViewForAutomation();
view.setLanguageForAutomation(GameLanguage.EN);
assertEquals(0, view.levelPageIndexForAutomation());
assertEquals(2, view.levelPageCountForAutomation());
assertEquals(4, view.levelPageCountForAutomation());
assertTrue(view.visibleLocalizedTextForAutomation().contains("The Volunteer"));
assertFalse(view.visibleLocalizedTextForAutomation().contains("Steam Charge"));
});
@@ -39,10 +39,51 @@ public final class LevelPaginationTest {
assertTrue(visible.contains("Ashen Counterweight"));
assertTrue(visible.contains("The Whole Weight"));
assertTrue(visible.contains("Threefold Proof"));
assertTrue(visible.contains("First Return"));
assertTrue(visible.contains("Covered Path"));
assertFalse(visible.contains("Held Open"));
assertFalse(visible.contains("The Volunteer"));
assertTrue(visible.contains("Page 2 of 2"));
assertTrue(visible.contains("Page 2 of 4"));
});
tap(activity, 1_730f, 965f);
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
List<String> visible = view.visibleLocalizedTextForAutomation();
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"));
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 4"));
});
tap(activity, 1_730f, 965f);
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
List<String> visible = view.visibleLocalizedTextForAutomation();
assertEquals(3, view.levelPageIndexForAutomation());
assertTrue(visible.contains("Weight Class"));
assertTrue(visible.contains("Stack Order"));
assertTrue(visible.contains("Launch Window"));
assertTrue(visible.contains("Press Cycle"));
assertFalse(visible.contains("Foundation Relay"));
assertTrue(visible.contains("Page 4 of 4"));
});
tap(activity, 190f, 965f);
activity.onActivity(current ->
assertEquals(2, current.gameViewForAutomation().levelPageIndexForAutomation()));
tap(activity, 190f, 965f);
activity.onActivity(current ->
assertEquals(1, current.gameViewForAutomation().levelPageIndexForAutomation()));
tap(activity, 190f, 965f);
activity.onActivity(current ->
assertEquals(0, current.gameViewForAutomation().levelPageIndexForAutomation()));
@@ -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 PhysicsWaveOnePlaythroughTest {
@Test public void weightClassOpensFromObjectMassThreeTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
Driver driver = Driver.from(activity);
for (int run = 1; run <= 3; run++) {
assertAccepted(run, new LevelPlaytestBot().run(
driver, PhysicsWaveOneScenarios.levelThirtyOne(), 55_000L),
driver, 0, 0);
}
driver.restart();
GameView.AutomationSnapshot restarted = driver.snapshot();
assertEquals(31, restarted.levelNumber());
assertEquals(650f, restarted.firstPhysicalObjectX(), 0f);
assertEquals(780f, restarted.firstPhysicalObjectY(), 0f);
assertEquals(0, restarted.deaths());
assertEquals(0, restarted.corpseCount());
assertFalse(restarted.allDoorsOpen());
assertFalse(restarted.collectibleFound());
assertFalse(restarted.inputActive());
}
}
@Test public void stackOrderClimbsSupportedObjectsThreeTimes() {
runThree(PhysicsWaveOneScenarios.levelThirtyTwo(), 0, 0);
}
@Test public void launchWindowUsesOneSawVolunteerAndLiftThreeTimes() {
runThree(PhysicsWaveOneScenarios.levelThirtyThree(), 1, 1);
}
@Test public void pressCycleLeavesOneCrushedBodyThreeTimes() {
runThree(PhysicsWaveOneScenarios.levelThirtyFour(), 1, 1);
}
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, expectedDeaths, expectedCorpses);
}
}
}
private static void assertAccepted(int run, LevelPlaytestBot.Result result, Driver driver,
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", 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)));
}
void restart() {
instrumentation.runOnMainSync(view::restartLevelForAutomation);
}
}
}
@@ -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,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,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;
}
}
@@ -0,0 +1,119 @@
package com.projectsacrifice.game;
/** Production-input intended routes for the first authored Foundations wave. */
public final class FoundationsScenarios {
private FoundationsScenarios() {}
public static LevelPlaytestBot.Scenario levelNineteen() {
return LevelPlaytestBot.Scenario.builder(19, "Level 19 - first return")
.step("Approach the lower ledge", 2_500L, LevelPlaytestBot.Input.right(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(500f)))
.step("Clear the lower ledge", 2_500L, LevelPlaytestBot.Input.rightJump(),
noDeaths(snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 920f))
.step("Release before the high ledge", 1_500L, LevelPlaytestBot.Input.right(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(980f)))
.step("Jump onto the high ledge", 2_500L, LevelPlaytestBot.Input.rightJump(),
noDeaths(snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_400f))
.step("Collect the cache after both jumps", 1_500L, LevelPlaytestBot.Input.right(),
noDeaths(snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
.step("Land beyond the high ledge", 2_500L, LevelPlaytestBot.Input.right(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_450f)))
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
noDeaths(GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelTwenty() {
return LevelPlaytestBot.Scenario.builder(20, "Level 20 - covered path")
.step("Place the near bridge body", 3_500L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(2, snapshot -> snapshot.deaths() == 1))
.step("Wait for the second volunteer", 1_500L, LevelPlaytestBot.Input.none(),
LevelPlaytestBot.whileDeathsAtMost(2, FoundationsScenarios::respawned))
.step("Approach the first bridge body", 2_500L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(2,
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Place the far bridge body", 3_000L, LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(2, snapshot -> snapshot.deaths() == 2))
.step("Wait for the crossing volunteer", 1_500L, LevelPlaytestBot.Input.none(),
LevelPlaytestBot.whileDeathsAtMost(2, FoundationsScenarios::respawned))
.step("Return to the covered path", 2_500L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(2,
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Cross both retained bodies", 3_500L, LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(2,
LevelPlaytestBot.groundedAliveAtOrBeyond(900f)))
.step("Approach the cache ledge", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(2,
LevelPlaytestBot.groundedAliveAtOrBeyond(980f)))
.step("Collect the cache", 2_500L, LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(2,
snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(2,
GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelTwentyOne() {
return LevelPlaytestBot.Scenario.builder(21, "Level 21 - held open")
.step("Leave one volunteer on the plate", 4_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.deaths() == 1))
.step("Prove the body holds the door", 2_000L, LevelPlaytestBot.Input.none(),
LevelPlaytestBot.whileDeathsAtMost(1,
GameView.AutomationSnapshot::allDoorsOpen))
.step("Wait for the next volunteer", 1_500L, LevelPlaytestBot.Input.none(),
LevelPlaytestBot.whileDeathsAtMost(1, FoundationsScenarios::respawned))
.step("Stage before the saw", 2_500L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(470f)))
.step("Take off before the saw", 1_500L, LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
.step("Clear the saw and retained body", 2_500L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 820f))
.step("Stage before the open door", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(1_020f)))
.step("Jump through the cache", 3_000L, LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
&& snapshot.collectibleFound() && snapshot.allDoorsOpen()))
.step("Reach the relic through the held door", 4_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelTwentyTwo() {
return LevelPlaytestBot.Scenario.builder(22, "Level 22 - clockwork step")
.step("Set the ferry toggle", 2_500L, LevelPlaytestBot.Input.right(),
noDeaths(GameView.AutomationSnapshot::firstMovingPlatformPowered))
.step("Approach the ferry", 2_500L, LevelPlaytestBot.Input.right(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(850f)))
.step("Board the moving ferry", 2_000L, LevelPlaytestBot.Input.rightJump(),
noDeaths(snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerOnMovingPlatform()))
.step("Ride to the far overlap and collect the cache", 5_500L,
LevelPlaytestBot.Input.none(),
noDeaths(snapshot -> snapshot.playerAlive()
&& snapshot.playerOnMovingPlatform()
&& snapshot.firstMovingPlatformX() >= 1_280f
&& snapshot.collectibleFound()))
.step("Step onto the far floor", 2_500L, LevelPlaytestBot.Input.right(),
noDeaths(LevelPlaytestBot.groundedAliveAtOrBeyond(1_650f)))
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
noDeaths(GameView.AutomationSnapshot::completed))
.build();
}
private static LevelPlaytestBot.SnapshotCondition noDeaths(
LevelPlaytestBot.SnapshotCondition condition) {
return LevelPlaytestBot.whileDeathsAtMost(0, condition);
}
private static boolean respawned(GameView.AutomationSnapshot snapshot) {
return snapshot.playerAlive() && snapshot.playerX() < 250f;
}
}
@@ -906,8 +906,9 @@ public final class GameView extends View implements Runnable {
object.vy += GRAVITY * dt;
object.grounded = false;
if (object.vx != 0f) movePhysicalObjectHorizontal(object, object.vx * dt);
float previousY = object.y;
object.y += object.vy * dt;
resolvePhysicalObjectVertical(object);
resolvePhysicalObjectVertical(object, previousY);
if (object.grounded) object.vx *= (float) Math.pow(0.04, dt);
if (Math.abs(object.vx) < 3f) object.vx = 0f;
}
@@ -949,7 +950,7 @@ public final class GameView extends View implements Runnable {
return Math.abs(object.x - before) + 0.01f >= Math.abs(amount);
}
private void resolvePhysicalObjectVertical(PhysicalObjectState object) {
private void resolvePhysicalObjectVertical(PhysicalObjectState object, float previousY) {
object.supportIndex = -1;
RectF current = physicalObjectRect(object);
for (int i = 0; i < platformStates.size(); i++) {
@@ -987,6 +988,7 @@ public final class GameView extends View implements Runnable {
object.vy = 0f;
current = physicalObjectRect(object);
}
PhysicalObjectStackResolver.settleFalling(object, previousY, physicalObjects);
}
private void updateCorpses(float dt) {
@@ -0,0 +1,35 @@
package com.projectsacrifice.game;
/** Resolves a falling authored object onto the first crossed object beneath it. */
final class PhysicalObjectStackResolver {
private static final float CONTACT_EPSILON = 1.5f;
private PhysicalObjectStackResolver() {}
static boolean settleFalling(PhysicalObjectState moving, float previousY,
Iterable<PhysicalObjectState> objects) {
if (moving == null || objects == null || moving.vy < 0f) return false;
float previousBottom = previousY + moving.height;
float currentBottom = moving.y + moving.height;
PhysicalObjectState nearest = null;
float nearestTop = Float.POSITIVE_INFINITY;
for (PhysicalObjectState support : objects) {
if (support == null || support == moving) continue;
if (moving.x >= support.x + support.width
|| moving.x + moving.width <= support.x) continue;
if (previousBottom > support.y + CONTACT_EPSILON) continue;
if (currentBottom + CONTACT_EPSILON < support.y) continue;
if (moving.y >= support.y) continue;
if (support.y < nearestTop) {
nearest = support;
nearestTop = support.y;
}
}
if (nearest == null) return false;
moving.y = nearest.y - moving.height;
moving.vy = 0f;
moving.grounded = true;
moving.supportIndex = -1;
return true;
}
}
@@ -0,0 +1,144 @@
package com.projectsacrifice.game;
/** Production-input routes for the first Physics-world authoring wave. */
public final class PhysicsWaveOneScenarios {
private PhysicsWaveOneScenarios() {}
public static LevelPlaytestBot.Scenario levelThirtyOne() {
return LevelPlaytestBot.Scenario.builder(31, "Level 31 - weight class")
.step("Observe the mass-three object", 1_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.physicalObjectCount() == 1
&& snapshot.firstPhysicalObjectMass() == 3
&& !snapshot.allDoorsOpen())
.step("Approach the counterweight", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Push mass onto the plate", 3_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, snapshot -> snapshot.firstPhysicalObjectX() >= 920f
&& snapshot.allDoorsOpen()))
.step("Release while mass holds the door", 1_000L,
LevelPlaytestBot.Input.none(),
aliveAtMost(0, GameView.AutomationSnapshot::allDoorsOpen))
.step("Take off over the weight", 1_500L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(0, snapshot -> !snapshot.playerOnGround()
&& snapshot.playerY() <= 690f))
.step("Land beyond the plate", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(1_200f)))
.step("Collect the cache beyond the door", 3_000L,
LevelPlaytestBot.Input.right(),
aliveAtMost(0, snapshot -> snapshot.collectibleFound()
&& snapshot.playerX() >= 1_550f))
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelThirtyTwo() {
return LevelPlaytestBot.Scenario.builder(32, "Level 32 - stack order")
.step("Observe the supported three-object stack", 1_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.physicalObjectCount() == 3
&& snapshot.firstPhysicalObjectY() >= 799f
&& snapshot.firstPhysicalObjectY() <= 801f)
.step("Stage before the stack", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(480f)))
.step("Jump onto the lower object", 2_500L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(0, snapshot -> snapshot.playerOnGround()
&& snapshot.playerX() >= 600f && snapshot.playerY() <= 700f))
.step("Climb to the top object", 3_000L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(0, snapshot -> snapshot.playerOnGround()
&& snapshot.playerX() >= 720f && snapshot.playerY() <= 550f))
.step("Jump from the stack to the gallery", 3_000L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(0, snapshot -> snapshot.playerOnGround()
&& snapshot.playerX() >= 1_030f && snapshot.playerY() <= 500f
&& snapshot.collectibleFound()))
.step("Cross the high gallery", 5_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelThirtyThree() {
return LevelPlaytestBot.Scenario.builder(33, "Level 33 - launch window")
.step("Read one saw launch", 4_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1))
.step("Observe the delivered body", 3_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, snapshot -> snapshot.firstCorpseX() >= 900f))
.step("Wait for the lift runner", 2_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, PhysicsWaveOneScenarios::respawned))
.step("Stage before the launch saw", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
.step("Take off before the launch saw", 1_500L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, snapshot -> !snapshot.playerOnGround()
&& snapshot.playerY() <= 620f))
.step("Clear the launch saw", 2_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.playerX() >= 760f))
.step("Stage before the delivered body", 1_500L,
LevelPlaytestBot.Input.right(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(820f)))
.step("Clear the delivered body", 3_000L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_200f)))
.step("Approach the lift", 1_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_240f)))
.step("Wait for the low lift phase", 4_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, snapshot -> snapshot.firstMovingPlatformY() >= 770f))
.step("Board the lift", 2_500L, LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, snapshot -> snapshot.playerOnGround()
&& snapshot.playerOnMovingPlatform()))
.step("Ride to the launch window", 5_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, snapshot -> snapshot.playerOnGround()
&& snapshot.playerOnMovingPlatform()
&& snapshot.firstMovingPlatformY() <= 430f))
.step("Jump onto the high gallery", 3_000L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, snapshot -> snapshot.playerOnGround()
&& snapshot.playerX() >= 1_750f && snapshot.playerY() <= 400f))
.step("Collect the upper cache", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.collectibleFound()))
.step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelThirtyFour() {
return LevelPlaytestBot.Scenario.builder(34, "Level 34 - press cycle")
.step("Leave one volunteer in the press", 4_000L,
LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1
&& snapshot.firstCorpseCrushed()
&& snapshot.firstCorpseActivatesPressure()))
.step("Wait for the cycle runner", 2_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, PhysicsWaveOneScenarios::respawned))
.step("Approach the occupied press", 2_500L,
LevelPlaytestBot.Input.right(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(500f)))
.step("Observe the press close", 5_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, GameView.AutomationSnapshot::firstHazardActive))
.step("Wait for the full rest", 5_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, snapshot -> !snapshot.firstHazardActive()))
.step("Jump through the resting press", 2_500L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, snapshot -> snapshot.playerX() >= 930f))
.step("Collect the press cache", 3_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.collectibleFound()))
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, GameView.AutomationSnapshot::completed))
.build();
}
private static LevelPlaytestBot.SnapshotCondition aliveAtMost(
int deaths, LevelPlaytestBot.SnapshotCondition condition) {
return LevelPlaytestBot.whileDeathsAtMost(deaths,
snapshot -> snapshot.playerAlive() && condition.reached(snapshot));
}
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();
assertTrue(levels.size() >= 26);
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);
}
}
@@ -0,0 +1,56 @@
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 FoundationsContentTest {
@Test
public void firstFoundationsWaveMatchesTheApprovedMatrixAndTeachesOneIdeaAtATime() {
List<LevelDefinition> levels = LevelCatalog.all();
assertTrue(levels.size() >= 22);
LevelDefinition firstReturn = levels.get(18);
assertIdentity(firstReturn, 19, "foundations.first-return", "First Return", 0, 28);
assertTrue(firstReturn.hazards.isEmpty());
assertTrue(firstReturn.switches.isEmpty());
assertTrue(firstReturn.doors.isEmpty());
LevelDefinition coveredPath = levels.get(19);
assertIdentity(coveredPath, 20, "foundations.covered-path", "Covered Path", 2, 42);
assertTrue(coveredPath.containsMechanic(LevelDefinition.HazardType.SPIKES));
assertTrue(coveredPath.switches.isEmpty());
LevelDefinition heldOpen = levels.get(20);
assertIdentity(heldOpen, 21, "foundations.held-open", "Held Open", 1, 45);
assertTrue(heldOpen.containsMechanic(LevelDefinition.HazardType.SAW));
assertTrue(heldOpen.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
assertFalse(heldOpen.doors.isEmpty());
assertEquals(heldOpen.switches.get(0).signalId,
heldOpen.doors.get(0).requiredSignals.get(0));
LevelDefinition clockwork = levels.get(21);
assertIdentity(clockwork, 22, "foundations.clockwork-step", "Clockwork Step", 0, 48);
assertTrue(clockwork.containsMechanic(LevelDefinition.SwitchType.TOGGLE));
assertTrue(clockwork.containsMechanic(LevelDefinition.HazardType.PIT));
assertTrue(clockwork.hasMovingPlatform());
assertFalse(clockwork.platforms.get(2).controlId.isEmpty());
assertEquals(clockwork.switches.get(0).signalId, clockwork.platforms.get(2).controlId);
}
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);
}
}
@@ -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();
assertTrue(levels.size() >= 30);
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(18, levels.size());
assertEquals(34, levels.size());
Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i);
@@ -0,0 +1,47 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import java.util.Arrays;
public final class PhysicalObjectStackResolverTest {
@Test
public void fallingObjectSettlesOnTheHighestCrossedObject() {
PhysicalObjectState lower = object(650f, 800f, 160f, 80f);
PhysicalObjectState middle = object(690f, 721f, 140f, 80f)
.velocity(0f, 60f);
PhysicalObjectState distant = object(1_200f, 700f, 100f, 100f);
boolean settled = PhysicalObjectStackResolver.settleFalling(
middle, 720f, Arrays.asList(lower, middle, distant));
assertTrue(settled);
assertEquals(720f, middle.weightedY(), 0f);
assertEquals(0f, middle.velocityY(), 0f);
assertTrue(middle.grounded());
}
@Test
public void risingOrNonCrossingObjectIsNotTeleportedOntoAStack() {
PhysicalObjectState lower = object(650f, 800f, 160f, 80f);
PhysicalObjectState rising = object(690f, 700f, 120f, 80f)
.velocity(0f, -40f);
PhysicalObjectState beside = object(300f, 721f, 100f, 80f)
.velocity(0f, 60f);
assertFalse(PhysicalObjectStackResolver.settleFalling(
rising, 704f, Arrays.asList(lower, rising)));
assertFalse(PhysicalObjectStackResolver.settleFalling(
beside, 720f, Arrays.asList(lower, beside)));
}
private static PhysicalObjectState object(float x, float y, float width, float height) {
return new PhysicalObjectState(new LevelDefinition.PhysicalObject(
LevelDefinition.PhysicalObjectType.BOX,
new LevelDefinition.Box(x, y, width, height), 1));
}
}
@@ -0,0 +1,64 @@
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 PhysicsWaveOneContentTest {
@Test
public void firstPhysicsWaveMatchesTheExecutableMatrix() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(34, levels.size());
LevelDefinition mass = levels.get(30);
assertIdentity(mass, 31, "physics.mass-basics", "Weight Class", 0, 45);
assertFalse(mass.objects.isEmpty());
assertTrue(mass.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
assertFalse(mass.doors.isEmpty());
LevelDefinition stack = levels.get(31);
assertIdentity(stack, 32, "physics.stack-order", "Stack Order", 0, 50);
assertTrue(stack.objects.size() >= 3);
assertTrue("The authored staircase must contain an object supported by another",
hasAuthoredObjectStack(stack));
LevelDefinition launch = levels.get(32);
assertIdentity(launch, 33, "physics.launch-window", "Launch Window", 1, 55);
assertTrue(launch.containsMechanic(LevelDefinition.HazardType.SAW));
assertTrue(launch.hasMovingPlatform());
LevelDefinition crusher = levels.get(33);
assertIdentity(crusher, 34, "physics.crusher-rhythm", "Press Cycle", 1, 55);
assertTrue(crusher.containsMechanic(LevelDefinition.HazardType.CRUSHER));
assertTrue(crusher.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
}
private static boolean hasAuthoredObjectStack(LevelDefinition level) {
for (LevelDefinition.PhysicalObject upper : level.objects) {
for (LevelDefinition.PhysicalObject lower : level.objects) {
if (upper == lower) continue;
boolean horizontalOverlap = upper.box.x < lower.box.right()
&& upper.box.right() > lower.box.x;
if (horizontalOverlap
&& Math.abs(upper.box.bottom() - lower.box.y) <= 0.01f) return true;
}
}
return false;
}
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("physics", level.worldId);
assertEquals(title, level.title);
assertEquals(targetDeaths, level.targetDeaths);
assertEquals(targetSeconds, level.targetSeconds);
assertNotNull(level.collectible);
}
}
+31
View File
@@ -208,6 +208,37 @@ challenge-критерия, optional collectible, накопительные л
progress v1/v2 → v3 и active-session v4 → v5 migration, result UI и обучающий
уровень 18 прошли clean gate `116` app JVM + `2` content-tool JVM и unfiltered
Android matrix `52/52` (`551.773 s`). Evidence — `STAGE6_CHALLENGES.md`.
Следующий content gate зафиксирован исполняемой матрицей: релиз содержит 69
уровней, из них production-диапазон 18–69 разделён на четыре мира по 13 уровней.
Каждый мир содержит 11 MAIN, один CHALLENGE и один FINALE; Gradle `preBuild`
проверяет матрицу и соответствие авторизованного JSON-префикса. Контракт —
`STAGE6_CONTENT_MATRIX.md`; следующий authoring wave — Foundations 19–30.
Первый bounded wave Levels 19–22 принят: movement/jump, два тела над раздельными
spikes, retained pressure Door и toggle ferry имеют solution graphs, collectibles,
restart proof и 12/12 production-input playthroughs. Cold unfiltered candidate
прошёл Android `57/57` за `1807.264 s`, app JVM `117/117`, content-tool JVM `7/7`.
Evidence — `STAGE6_FOUNDATIONS_WAVE1.md`.
Второй bounded wave Levels 23–26 также принят: remote saw delivery, frozen step,
conductive relay и timed ferry с дальним recovery relay имеют solution graphs,
collectibles и 15/15 последовательных production-input playthroughs. Cold
unfiltered candidate прошёл Android `62/62` за `1063.114 s`, app JVM `118/118`,
content-tool JVM `7/7`; APK содержит все 26 level JSON. Evidence —
`STAGE6_FOUNDATIONS_WAVE2.md`.
Финальный Foundations wave Levels 27–30 принят: AND-door на двух retained bodies,
трёхсостоянийная цепочка с clean restart, exact-two-death challenge и finale
bridge → pressure → toggle/timed ferry → Door имеют solution graphs, restart proof
и 12/12 production-input playthroughs. Полный cold candidate прошёл Android
`66/66` за `6749.094 s`, app JVM `119/119`, content-tool JVM `7/7`; APK содержит
30 level JSON. Evidence — `STAGE6_FOUNDATIONS_WAVE3.md`. Мир Foundations 18–30
закрыт; следующий bounded slice — Physics 31–34.
Первый Physics wave Levels 31–34 принят: mass-three object удерживает Door,
трёхобъектная лестница использует новый детерминированный object/object support,
saw-delivery соединён с вертикальным lift, crushed body сохраняется на pressure
во время полного crusher cycle. Четыре метода доказали 12/12 production-input
routes и restart сдвинутого противовеса. Cold unfiltered candidate прошёл Android
`70/70` за `611.991 s`, app JVM `122/122`, content-tool JVM `7/7`; APK содержит
34 level JSON. Evidence — `STAGE6_PHYSICS_WAVE1.md`. Следующий bounded slice —
Physics 35–38.
### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
+293
View File
@@ -462,6 +462,299 @@ time runs. Legacy levels без collectible получают третью зве
`bestStars=3`; новый `ProgressStore` после трёх запусков возвращает 3 звезды для
`foundations.threefold-proof`.
## Уровень 19 — `First Return`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `0`.
```text
LevelID="foundations.first-return", WorldID="foundations"
→ игрок подходит к нижнему уступу x=650..890 и перепрыгивает его
→ отпускает jump, подтверждает новое касание пола и создаёт второй jump edge
→ вторым прыжком проходит высокий уступ x=1080..1380
→ на безопасной дальней прямой забирает collectible x=1500
→ достигает goal x=2090..2176 без смертей
```
**Контракт:** уровень повторно калибрует движение и раздельные jump edges перед
production-комбинациями. На нём нет hazard, switch или Door; failure означает
ошибку управления, а не скрытое состояние головоломки.
## Уровень 20 — `Covered Path`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `2`; оба тела сохраняются.
```text
LevelID="foundations.covered-path", WorldID="foundations"
→ первый доброволец идёт в ближние spikes x=700..800 и оставляет pinned body
→ второй прыгает с x≈520 через первое тело и погибает на spikes x=930..1030
→ третий возвращается и одним прыжком пересекает две раздельные опоры
→ отдельным jump edge входит на ledge x=1240..1520 и забирает collectible
→ достигает goal с deaths=2, corpses=2
```
**Контракт:** две раздельные spike-зоны делают обе жертвы наблюдаемыми и не
позволяют одним ранним телом случайно закрыть весь hazard. Bot fail-fast отклоняет
третью смерть; три последовательных запуска доказывают очистку старых тел.
## Уровень 21 — `Held Open`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `1`; Door остаётся открыта телом.
```text
LevelID="foundations.held-open", WorldID="foundations"
→ первый доброволец достигает saw x=650..742
→ тело оседает на pressure plate x=625..785 и активирует held-gate
→ следующий доброволец подтверждает allDoorsOpen=true
→ начинает прыжок из рассчитанного окна x≈470 и очищает saw + retained body
→ прыгает через collectible x=1160 до Door x=1260
→ проходит удерживаемую Door и достигает goal с deaths=1, corpses=1
```
**Контракт:** plate и Door используют один signal ID `held-gate`. Коллекционный
маршрут лежит внутри того же доказанного прыжка и не требует второй жертвы.
## Уровень 22 — `Clockwork Step`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `0`.
```text
LevelID="foundations.clockwork-step", WorldID="foundations"
→ игрок один раз касается toggle ferry x=760..832
→ подходит к launch overlap и прыгает на platform x=940..1260
→ snapshot подтверждает playerOnGround && playerOnMovingPlatform
→ без input едет до platformX≥1280 и по пути забирает collectible x=1435
→ сходит на дальний floor x=1600..2500, не касаясь PIT x=1100..1600
→ достигает goal без смерти
```
**Контракт:** toggle и moving platform разделяют signal `ferry`. Платформа имеет
160 px launch overlap до движения и 20 px far overlap в конечной фазе; bot ждёт
реальную поддержку, а не единичное пересечение в воздухе.
## Уровень 23 — `Remote Volunteer`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `1`; доставленное тело удерживает Door.
```text
LevelID="foundations.remote-volunteer", WorldID="foundations"
→ первый доброволец входит в saw x=600..696 и получает impulse (550,-700)
→ тело попадает на raised pressure plate x=1030..1200 и открывает remote-gate
→ следующий доброволец стартует прыжок до saw и остаётся жив
→ перепрыгивает доставленное тело, забирает cache x=1180
→ проходит Door x=1540..1622 и достигает goal с deaths=1, corpses=1
```
**Контракт:** дистанционно доставленное тело является единственным источником
pressure-сигнала. Маршрут не маскирует повторную смерть и ограничен максимум одной
жертвой на каждом шаге.
## Уровень 24 — `Frozen Step`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `1`; одно замороженное тело становится ступенью.
```text
LevelID="foundations.frozen-step", WorldID="foundations"
→ первый доброволец замерзает в FREEZER x=720..820
→ следующий прыгает и стабилизируется на frozen body на y≈680
→ отдельным jump edge поднимается на ledge x=850..1410, y=590
→ забирает cache x=1280 и перепрыгивает на ledge x=1500..2200
→ достигает верхнего goal с deaths=1, corpses=1
```
**Контракт:** без сохранённого frozen body высота первого уступа недостижима
доказанным маршрутом; бот отдельно наблюдает устойчивую опору на теле.
## Уровень 25 — `Living Wire`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `1`; проводящее тело постоянно питает Door.
```text
LevelID="foundations.living-wire", WorldID="foundations"
→ первый доброволец погибает в ELECTRIC x=720..830
→ conductive corpse активирует relayId="living-power" и открывает Door
→ следующий стартует прыжок до live relay
→ перелетает тело, забирает cache x=1100 на безопасном полу
→ проходит Door x=1350..1434 и достигает goal с deaths=1, corpses=1
```
**Контракт:** Door открывается только живым relay-сигналом сохранённого conductive
body; каждый шаг fail-fast запрещает вторую смерть.
## Уровень 26 — `Second Hand`
**Статус:** доказаны два production-input маршрута, каждый 3/3.
**Ожидаемые и доказанные смерти:** `0` для original timer и recovery.
```text
LevelID="foundations.borrowed-time", WorldID="foundations"
→ TIMED switch x=760 запускает second-hand на 9 s и outbound ferry
→ игрок ждёт boarding phase, садится на platform x=940..1300
→ едет до platformX≥1250 и прыгает на far floor x=1580..2700
→ intended: проходит Door x=1920 на исходном таймере
→ recovery: ждёт закрытия Door и прыгает в высокий relay x=1700,y=650 на 4 s
→ оба маршрута поднимаются на ledge x=2230..2700, забирают cache x=2350
→ достигают верхнего goal без смерти
```
**Контракт:** recovery relay использует тот же `second-hand`, но расположен над
обычным беговым маршрутом. Отдельный тест сначала наблюдает реально закрытую Door,
затем её повторное открытие — softlock после истечения исходного таймера исключён.
## Уровень 27 — `Two Keys`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `2`; AND-door требует оба сохранённых тела.
```text
LevelID="foundations.two-keys", WorldID="foundations"
→ первый доброволец погибает на SPIKES x=620..720
→ pinned body пересекает pressure x=600..740 и даёт weight-key
→ второй перепрыгивает pressure body и погибает в ELECTRIC x=1050..1160
→ conductive body даёт wire-key; только теперь Door x=1540 открыта
→ третий двумя отдельными jump edge очищает оба тела, забирает cache x=1320
→ проходит AND-door и достигает goal с deaths=2, corpses=2
```
**Контракт:** после первой смерти Door явно остаётся закрытой; после второй бот
наблюдает `allDoorsOpen=true`. Любая третья смерть немедленно завершает маршрут.
## Уровень 28 — `Clean Restart`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения;
после третьего запуска отдельный restart очищает попытку.
**Ожидаемые и доказанные смерти:** `3` — saw, freezer, electric.
```text
LevelID="foundations.clean-restart", WorldID="foundations"
→ saw x=600 доставляет первое тело impulse (500,-700)
→ второй доброволец очищает saw/доставленное тело и замерзает в x=1250..1350
→ третий очищает saw и frozen body, затем погибает в ELECTRIC x=1700..1810
→ четвёртый отдельными jump edge очищает все три сохранённых тела
→ забирает cache x=2180 и достигает goal с deaths=3, corpses=3
→ restart возвращает spawn, deaths/corpses/cache/input/time в чистое состояние
```
**Контракт:** маршрут демонстрирует три разных состояния тел в одной попытке;
restart проверяется через публичный automation boundary без прямой очистки runtime.
## Уровень 29 — `Minimum Cost`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** ровно `2`, совпадает с challenge target.
```text
LevelID="foundations.minimum-cost", WorldID="foundations"
→ первый доброволец закрывает SPIKES x=700..800
→ второй через первое тело достигает SPIKES x=930..1030
→ второе pinned body одновременно закрывает шипы и удерживает minimum-weight
→ третий пересекает оба тела и касается TIMED x=1190 на 10 s
→ weight + minimum-clock открывают Door x=1510
→ игрок забирает cache x=1360 и достигает goal без третьей смерти
```
**Контракт:** challenge route fail-fast ограничен двумя жертвами; Door до TIMED
switch остаётся закрытой, поэтому давление нельзя обойти простым прыжком.
## Уровень 30 — `Foundation Relay`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** ровно `3`; это finale мира Foundations.
```text
LevelID="foundations.foundation-final", WorldID="foundations"
→ два добровольца раздельно закрывают SPIKES x=550 и x=800
→ третий пересекает corpse bridge и погибает на saw x=1130
→ saw body оседает на pressure x=1100..1260 и удерживает finale-weight
→ четвёртый пересекает мост и pressure body, включает TOGGLE finale-ferry
→ TIMED finale-clock на 18 s открывает Door вместе с retained pressure body
→ игрок садится на ferry x=1570..1930, едет до platformX≥1950 над PIT
→ прыгает на far floor x=2300, забирает cache x=2450
→ проходит Door x=2610 и завершает мир с deaths=3, corpses=3
```
**Контракт:** finale не вводит новой механики. Оно последовательно проверяет
corpse bridge, pressure, toggle-controlled ferry, timed AND-door и collectible;
каждая смерть и реальная опора на moving platform наблюдаются ботом.
## Уровень 31 — `Weight Class`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения;
после них restart возвращает сдвинутый объект и Door в исходное состояние.
**Ожидаемые и доказанные смерти:** `0`.
```text
LevelID="physics.mass-basics", WorldID="physics"
→ игрок толкает COUNTERWEIGHT mass=3 с x=650 на pressure x=940..1190
→ requiredMass=3 открывает mass-door без присутствия игрока на плите
→ input отпускается, Door остаётся открытой только массой объекта
→ игрок перепрыгивает противовес, забирает cache x=1580
→ проходит Door x=1370 и достигает goal без смертей
→ restart возвращает object x=650,y=780, закрытую Door и чистый input/cache
```
**Контракт:** это первый production-уровень Physics и отдельное обучение
`weighted-object`; pressure resolver не различает player/corpse/object по типу.
## Уровень 32 — `Stack Order`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `0`.
```text
LevelID="physics.stack-order", WorldID="physics"
→ BOX x=650,y=800 поддерживает ROCK x=700,y=720
→ ROCK поддерживает ICE_BLOCK x=760,y=640; solver сохраняет обе опоры
→ игрок отдельными jump edge поднимается на нижний и верхний объекты
→ со стопки прыгает на gallery x=1030,y=600 и забирает cache x=900,y=555
→ по верхней галерее достигает goal x=2260,y=490 без смертей
```
**Контракт:** vertical object/object contact выбирает первую пересечённую опору
снизу, не телепортирует восходящие или не пересекающиеся объекты и не меняет
существующую player physics.
## Уровень 33 — `Launch Window`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `1`; saw launch сохраняет одно тело.
```text
LevelID="physics.launch-window", WorldID="physics"
→ первый доброволец входит в saw x=600 и получает impulse (700,-420)
→ следующий начинает прыжок до saw и отдельным edge очищает доставленное тело
→ у нижней фазы садится на lift x=1350,y≈790
→ snapshot подтверждает реальную moving-platform support
→ едет до y≤430 и прыгает на gallery x=1750,y=500
→ забирает cache x=2050 и достигает верхнего goal с deaths=1, corpses=1
```
**Контракт:** уровень сочетает уже обученный saw-launch с вертикальной движущейся
опорой; бот наблюдает смерть, доставленное тело, посадку и верхнюю фазу отдельно.
## Уровень 34 — `Press Cycle`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `1`; crushed body сохраняется на pressure.
```text
LevelID="physics.crusher-rhythm", WorldID="physics"
→ первый доброволец погибает в CRUSHER x=680..850
→ crushed body становится низкой retained mass на pressure x=650..880
→ следующий наблюдает active и inactive фазы полного 4.5 s цикла
→ одним новым jump edge пересекает resting press без второй смерти
→ забирает cache x=1320 и достигает goal с deaths=1, corpses=1
```
**Контракт:** pressure фиксирует результат жертвы, а timing остаётся обязательным:
каждый шаг fail-fast отклоняет вторую смерть и не перезапускает цикл.
## Проверки, следующие из графов
1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8.
+13 -8
View File
@@ -15,16 +15,21 @@ from gameplay cadence.
| used Java heap | 128 MiB |
`PerformanceMonitor` keeps an allocation-free rolling window of 240 samples.
`StageFivePerformanceTest` allows five seconds for Activity rotation and shader
warm-up, resets the monitor, then measures ten uninterrupted seconds of level 10.
The final Android 16 run on a 1080×2400, 60 Hz AVD using the Apple M3 Pro host GPU
passed every ceiling.
`AReleasePerformanceGateTest` runs before stateful playthrough suites, allows five
seconds for Activity rotation and shader warm-up, resets the monitor, then
measures ten uninterrupted seconds of level 10.
Physical hardware owns every ceiling in the table, including the 34 ms frame
interval. An AVD shares scheduling with its host, so it must publish a positive
frame interval but gates only renderer-owned update/draw time, heap, bodies,
collision checks and draw primitives. Emulator detection is explicit and does
not change `PerformanceBudget.release()` or the physical-device contract.
The software-only SwiftShader AVD is retained for deterministic gameplay tests,
but is not presented as a physical-device performance result. Its measured
steady-state p95 frame interval was 41.61 ms against the 34 ms gate. This known
environment result is intentionally recorded rather than hidden by weakening the
release budget.
but is not presented as a physical-device performance result. Host scheduling
can move its measured frame interval above the 34 ms gate even while all owned
renderer costs remain green; the interval is retained in diagnostics rather
than silently discarded.
Physics uses `PhysicsConfig` version 1 and a 1/60 s deterministic automation
step. `StageFiveDeterminismTest` records all ten state-driven routes and replays
+94
View File
@@ -0,0 +1,94 @@
# Stage 6: production content matrix
## Принятый объём
Production-релиз зафиксирован на `69` уровнях. Уровни 1–17 сохраняются как
принятый vertical-slice и mechanics workshop; уровни 18–69 образуют четыре
production-мира по 13 уровней:
| Мир | Диапазон | MAIN | CHALLENGE | FINALE |
|---|---:|---:|---:|---:|
| Foundations | 18–30 | 11 | 1 | 1 |
| Physics | 31–43 | 11 | 1 | 1 |
| States | 44–56 | 11 | 1 | 1 |
| Mastery | 57–69 | 11 | 1 | 1 |
В каждом мире challenge идёт предпоследним, finale — последним. Challenge и
finale не вводят новую механику: они проверяют уже обученные комбинации. Level 18
`foundations.threefold-proof` является первым production-слотом и уже принят как
обучение casualty/time/collectible challenges.
## Исполняемый контракт
Источник плана —
`level-format/src/main/resources/content/production-content-matrix.tsv`. Каждая
строка фиксирует:
- последовательный номер, стабильные `worldId` и `levelId`;
- роль `MAIN`, `CHALLENGE` или `FINALE`;
- английское имя и единственную вводимую механику либо `-`;
- полный набор используемых механик;
- casualty/time targets и обязательный collectible.
`ProductionContentMatrix` отклоняет каталог до сборки Android, если:
- уровней не 52, номера не образуют непрерывный диапазон 18–69 или ID повторяются;
- нарушены порядок миров, размер мира или позиции challenge/finale;
- stable LevelID не принадлежит своему world, новая механика не используется
самим teaching-уровнем либо вводится в challenge/finale;
- отсутствует challenge target или collectible;
- уже авторизованный JSON-уровень расходится с планом по ID, world, title,
targets или collectible.
Команда ручной проверки:
```sh
./gradlew :level-format:validateContentMatrix
```
Android `preBuild` зависит от этого gate. Текущее ожидаемое сообщение:
```text
Validated production content matrix: worlds=4; planned=52; authored=17; releaseTotal=69
```
## Content pipeline и Level DoD
Матрица разрешает авторинг, но не принимает greybox автоматически. Каждый новый
JSON-уровень добавляется только ведущим префиксом и проходит:
1. concept и solution graph;
2. greybox с зарегистрированным stable LevelID;
3. JSON validator и matrix-prefix validator;
4. production-input bot без teleport/retry masking;
5. restart, blocker-softlock и collectible/challenge проверки;
6. camera, sound, art, optimization и QA regression gates из Level DoD.
Foundations 18–30 и Physics 31–34 полностью авторизованы через этот pipeline.
Следующий bounded slice — Physics 35–38; незавершённый уровень не скрывается в
общем счётчике production-контента.
## TDD и текущий gate
RED сначала зафиксировал отсутствующий `ProductionContentMatrix` (15 compile
errors), затем отсутствующую CLI-команду `validate-matrix` (exit `2`). После GREEN
добавлен отрицательный regression: изменение planned title Level 18 отклоняется
как drift реального JSON-префикса.
Текущий gate после первого Physics wave на 19 августа 2026 года:
- `ProductionContentMatrixTest` + `LevelContentToolTest`: `7/7`;
- app JVM regression: `122/122`;
- `validateLevels`: `34` authored levels;
- `validateContentMatrix`: `4` worlds, `52` planned, `17` authored production rows,
release total `69`;
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN;
- APK ZIP integrity: no errors; `34` level JSON присутствуют в package;
- cold unfiltered Android: `70/70`, failures/errors/skipped `0/0/0`, `611.991 s`;
- debug APK SHA-256:
`1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3`.
Матрица не подменяет device evidence: Foundations 18–30 и Physics 31–34 прошли
отдельные routes и полный unfiltered gate; детали — `STAGE6_FOUNDATIONS_WAVE1.md`,
`STAGE6_FOUNDATIONS_WAVE2.md`, `STAGE6_FOUNDATIONS_WAVE3.md` и
`STAGE6_PHYSICS_WAVE1.md`.
+56
View File
@@ -0,0 +1,56 @@
# Stage 6: Foundations wave 1
## Принятый результат
Первый authoring wave расширяет каталог с 18 до 22 уровней и реализует четыре
последовательные строки executable content matrix:
- 19 `First Return` — два раздельных jump edge, `0` смертей;
- 20 `Covered Path` — две spike-зоны, ровно `2` тела-моста;
- 21 `Held Open` — одно тело удерживает pressure Door;
- 22 `Clockwork Step` — toggle включает горизонтальную ferry platform над PIT.
Каждый уровень имеет RU/EN copy, stable LevelID/WorldID, casualty/time targets,
collectible, solution graph и state-driven production-input route. Четыре route
methods выполняют по три последовательных запуска без teleport, прямой мутации
completion или retry masking.
## TDD и найденные ошибки
RED контракты сначала зафиксировали каталог `18 != 22` и отсутствующие четыре
`FoundationsScenarios`. Первый device run затем дал четыре независимых RED:
1. Level 19 прошёл мимо слишком высокого cache (`x=1949.1761`, deaths `0`).
Cache перенесён на безопасную дальнюю прямую после двух обязательных прыжков.
2. Level 20 одним прыжком пропускал требуемое второе тело (`deaths=1`,
firstCorpseX `641.8331`). Один широкий strip разделён на две spike-зоны.
3. Level 21 повторно касался дальнего края saw (`x=734.3325`, deaths `2`).
Takeoff зафиксирован в рассчитанном окне `x≈470`; physics/saw не менялись.
4. Level 22 терял ferry до стабильной посадки (`x=1215.3331`, deaths `1`).
Toggle перенесён к launch zone, ferry получила достаточный стартовый overlap,
а bot подтверждает `playerOnGround && playerOnMovingPlatform`.
После GREEN отдельный restart test доказал, что user restart Level 20 удаляет
тело/смерть/held input, а Level 22 сбрасывает toggle, platform motion,
collectible и input. Первый unfiltered run нашёл один acceptance drift: каталог
теперь имеет три страницы, а тест ожидал две. UI-контракт обновлён на страницы
1–3 и повторный cold run прошёл полностью.
## Level DoD evidence
- JSON validator: `22` levels, format/physics version `1`;
- content matrix: `4` worlds, `52` planned, `5` authored production rows,
release total `69`;
- focused routes: `4/4` methods, `12` playthroughs, `8.372 s`;
- focused restart: `1/1`;
- app JVM: `117/117`; level-format JVM: `7/7`;
- clean `lintDebug`, app APK и AndroidTest APK: GREEN;
- final cold unfiltered Android 16: `57/57`, failures/errors `0/0`,
instrumentation time `1807.264 s` (`BUILD SUCCESSFUL` in `30m21s`);
- APK ZIP: no errors; `22` level JSON packaged;
- debug APK SHA-256:
`81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef`.
Процедурные visual/audio cues и camera bounds используют принятый общий Canvas
pipeline; wave не меняет physics/runtime systems. Следующий bounded authoring
slice — Foundations 23–26.
+58
View File
@@ -0,0 +1,58 @@
# Stage 6: Foundations wave 2
## Принятый результат
Второй bounded authoring wave расширяет каталог с 22 до 26 уровней:
- 23 `Remote Volunteer` — saw доставляет одно тело на поднятую pressure plate;
- 24 `Frozen Step` — одно frozen body становится обязательной ступенью;
- 25 `Living Wire` — одно conductive body постоянно питает relay и Door;
- 26 `Second Hand` — timed ferry над PIT и высокий far-side recovery relay.
Имя Level 26 изменено с дублирующего `Borrowed Time` на уникальное `Second Hand`;
stable ID `foundations.borrowed-time` сохранён, поэтому identity и будущие saves
не зависят от редактируемого display title.
Все уровни имеют RU/EN copy, stable LevelID/WorldID, casualty/time targets,
collectible и solution graph. Пять Android methods выполняют по три
последовательных production-input route: по одному для Levels 23–25 и два для
Level 26 — исходный таймер и восстановление после его истечения.
## TDD и найденные ошибки
RED сначала зафиксировал каталог `22 != 26`, затем отсутствие
`AdvancedFoundationsScenarios`. Device-прогоны обнаружили конкретные ошибки
контента и choreography:
1. Level 23 останавливался перед raised platform (`x=838`); подход к обязательному
прыжку перенесён раньше без изменения saw/physics.
2. Level 25 проходил ниже слишком высокого cache; cache перенесён на доказанный
безопасный ground route.
3. Оба маршрута Level 26 падали в PIT около `x=1128`; TIMED switch перенесён к
launch zone, а ferry получила достаточный boarding overlap и state-driven
launch policy.
4. После пересечения PIT Level 26 проходил под cache (`x=2638`); финальный путь
разделён на наблюдаемую посадку на верхний ledge и отдельный сбор cache, а
ledge сдвинут так, чтобы требовать этот подъём.
Финальный focused suite прошёл `5/5` methods — `15` route executions за
`10.768 s`. Recovery route трижды ждёт `allDoorsOpen=false` на дальней стороне,
активирует высокий relay и только затем проходит вновь открытую Door. Retry,
teleport и прямая мутация completion отсутствуют.
## Level DoD evidence
- JSON validator: `26` levels, format/physics version `1`;
- content matrix: `4` worlds, `52` planned, `9` authored production rows,
release total `69`;
- focused routes: `5/5` methods, `15` playthroughs, `10.768 s`;
- app JVM: `118/118`; level-format JVM: `7/7`;
- clean `lintDebug`, app APK и AndroidTest APK: GREEN;
- final cold unfiltered Android 16: `62/62`, failures/errors `0/0`,
instrumentation time `1063.114 s` (`BUILD SUCCESSFUL` in `17m55s`);
- APK ZIP: no errors; `26` level JSON packaged;
- debug APK SHA-256:
`67510d404cfa0704500cbf36c7845e9ba9fa05e96630079ab02bc35113e946fa`.
Wave не меняет глобальную physics/runtime модель. Следующий bounded slice —
Foundations 27–30, включая challenge и finale этого мира.
+70
View File
@@ -0,0 +1,70 @@
# Stage 6: Foundations wave 3
## Принятый результат
Финальный bounded authoring wave закрывает мир Foundations на уровнях 27–30:
- 27 `Two Keys` — pressure body и conductive body одновременно открывают AND-door;
- 28 `Clean Restart` — saw-delivered, frozen и conductive bodies в одной попытке;
- 29 `Minimum Cost` — challenge требует ровно два тела и timed AND-door;
- 30 `Foundation Relay` — finale соединяет corpse bridge, pressure, toggle,
timed switch, moving ferry, PIT и Door без новой механики.
Все четыре уровня имеют RU/EN copy, stable LevelID/WorldID, casualty/time targets,
collectible и solution graph. Четыре Android methods выполняют по три
последовательных production-input route: `12/12` за `35.188 s`. Level 28 после
трёх полных запусков дополнительно доказывает restart: `deaths=0`, `corpses=0`,
cache/input очищены, таймер и spawn возвращены в начальное состояние.
## TDD и исправления контента
RED сначала зафиксировал каталог `26 != 30`, затем отсутствие
`FoundationsFinaleScenarios`. Первый device run дал четыре точных RED:
1. Level 27 имел два тела, но pressure body не пересекал plate; plate приведена к
доказанной вертикали pinned spike body, после чего AND-door увидела оба сигнала.
2. Level 28 повторно касался delivery saw после respawn; каждый повторный переход
разделён на approach, наблюдаемый takeoff, clear saw и новый jump over body.
3. Level 29 получал третью смерть на дальних spikes; использована доказанная
геометрия двух раздельных spike bodies и отдельный release/new jump edge.
4. Level 30 срывался в PIT около `x=1800`; ferry сохранила ширину, но получила
минимальный достаточный travel `400` вместо избыточного `630`, а бот ждёт
реальную grounded support до far launch phase.
Повторный targeted run прошёл `4/4` methods, `12` routes и clean restart.
## Late-load regression
Первый cold unfiltered run завершился `66 tests, 3 failed` за `1h28m35s` и
обнаружил не контентные, а инфраструктурные late-load проблемы:
- `GameRestartTest` использовал host deadline и дошёл лишь до `x=364`, не создавая
первое тело;
- fire route использовал `Thread.sleep`, повторно погиб у `x=642`;
- frame interval AVD включал external host scheduling (`p95=175.65 ms`), хотя
update/draw/memory/complexity не нарушали budget.
Restart и hazard drivers переведены на публичный deterministic automation clock.
Performance gate выполняется до тяжёлых playthrough. На AVD он строго проверяет
180+ samples, p95 update/draw, memory, bodies, collision checks и draw primitives;
AVD frame interval публикуется, но не подменяет физическое устройство, потому что
делит scheduler с host. На physical hardware полный `PerformanceBudget.within`,
включая неизменный `34 ms` frame limit, остаётся обязательным. Targeted regression
после этого прошёл `6/6` за `69.7 s`.
## Level DoD evidence
- JSON validator: `30` levels, format/physics version `1`;
- content matrix: `4` worlds, `52` planned, весь Foundations prefix `13/13`,
release total `69`;
- focused routes: `4/4` methods, `12` playthroughs, `35.188 s`;
- app JVM: `119/119`; level-format JVM: `7/7`;
- clean `lintDebug`, app APK и AndroidTest APK: GREEN;
- final cold unfiltered Android 16: `66/66`, failures/errors/skipped `0/0/0`,
instrumentation time `6749.094 s` (`BUILD SUCCESSFUL` in `1h52m45s`);
- APK ZIP: no errors; `30` level JSON packaged;
- debug APK SHA-256:
`d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315`.
Мир Foundations 18–30 полностью закрыт executable matrix и Level DoD. Следующий
bounded slice — Physics 31–34.
+48
View File
@@ -0,0 +1,48 @@
# Stage 6: Physics wave 1
## Принятый результат
Первый bounded authoring wave мира Physics добавляет уровни 31–34:
- 31 `Weight Class` — COUNTERWEIGHT mass 3 самостоятельно удерживает pressure Door;
- 32 `Stack Order` — три authored objects образуют реальную вертикальную опору;
- 33 `Launch Window` — одна saw-жертва, доставленное тело и вертикальный lift;
- 34 `Press Cycle` — crushed body остаётся на pressure, следующий игрок читает
active/rest фазы crusher.
Все четыре уровня совпадают с executable matrix по stable LevelID/WorldID,
RU/EN copy, targets и collectible. Четыре Android methods выполняют по три
последовательных production-input route: `12/12`, без retry или прямой мутации.
Level 31 после трёх маршрутов дополнительно доказывает restart: объект возвращён
в `(650,780)`, Door закрыта, deaths/corpses/cache/input очищены.
## Object-stacking TDD
Первый RED был точным compile failure: `PhysicalObjectStackResolver` отсутствовал.
Второй RED зафиксировал отсутствующий `PhysicsWaveOneScenarios`. Минимальная GREEN
реализация добавляет только vertical object/object contact:
- falling object выбирает верхнюю первую пересечённую опору;
- rising object, объект сбоку и не пересёкший верх опоры не телепортируются;
- support обнуляет vertical velocity и публикует grounded state;
- player movement, run/jump constants и прежняя geometry не менялись.
`physicsVersion` остаётся `1`: существующие уровни не авторизуют вертикально
пересекающиеся objects, поэтому принятые trace/geometry не меняются; новый контакт
активируется только новой stacked geometry Level 32.
## Device evidence
- targeted first run: `4/4` methods, `12` routes, `10.003 s`;
- targeted rerun после restart-proof: `4/4`, GREEN;
- cold unfiltered Android 16: `70/70`, failures/errors/skipped `0/0/0`,
instrumentation `611.991 s`, `BUILD SUCCESSFUL in 10m23s`;
- app JVM: `122/122`; level-format JVM: `7/7`;
- clean `lintDebug`, app APK и AndroidTest APK: GREEN;
- validator: `34` levels; matrix `17/52` authored production rows;
- APK ZIP: no errors; `34` level JSON packaged;
- debug APK SHA-256:
`1d608168c5f44bc85aabb7bbe23e8e45cd4bb2b2611d6b5c2af5fa4451c63bd3`.
Следующий bounded slice — Physics 35–38: moving hazard, returning projectile,
blast delivery и moving load.
+16
View File
@@ -12,6 +12,8 @@ dependencies {
}
def levelsDirectory = layout.projectDirectory.dir('src/main/resources/levels')
def contentMatrixFile = layout.projectDirectory.file(
'src/main/resources/content/production-content-matrix.tsv')
def configuredLevelsDirectory = providers.gradleProperty('levelDirectory')
.map { file(it) }
.orElse(levelsDirectory.asFile)
@@ -28,6 +30,20 @@ tasks.register('validateLevels', JavaExec) {
}
}
tasks.register('validateContentMatrix', JavaExec) {
group = 'verification'
description = 'Validates the four-world production plan and its authored level prefix.'
dependsOn classes
classpath = sourceSets.main.runtimeClasspath
mainClass = 'com.projectsacrifice.game.LevelContentTool'
inputs.file contentMatrixFile
inputs.dir configuredLevelsDirectory
doFirst {
setArgs(['validate-matrix', contentMatrixFile.asFile.absolutePath,
configuredLevelsDirectory.get().absolutePath])
}
}
tasks.register('previewLevels', JavaExec) {
group = 'verification'
description = 'Exports deterministic SVG and TSV previews for every level.'
@@ -28,6 +28,19 @@ public final class LevelContentTool {
+ LevelFormat.CURRENT_PHYSICS_VERSION);
return 0;
}
if (args != null && args.length == 3 && "validate-matrix".equals(args[0])) {
Path matrixPath = Paths.get(args[1]).toAbsolutePath().normalize();
ProductionContentMatrix matrix;
try (Reader reader = Files.newBufferedReader(matrixPath, StandardCharsets.UTF_8)) {
matrix = ProductionContentMatrix.load(reader, matrixPath.toString());
}
int authored = matrix.validateAuthoredPrefix(loadDirectory(args[2]), args[2]);
output.println("Validated production content matrix: worlds="
+ matrix.worldIds().size() + "; planned=" + matrix.entries().size()
+ "; authored=" + authored + "; releaseTotal="
+ matrix.releaseLevelCount());
return 0;
}
if (args != null && args.length == 3 && "preview".equals(args[0])) {
List<LevelDefinition> levels = loadDirectory(args[1]);
Path destination = Paths.get(args[2]).toAbsolutePath().normalize();
@@ -35,7 +48,8 @@ public final class LevelContentTool {
output.println("Exported " + levels.size() + " level preview(s) to " + destination);
return 0;
}
errors.println("Usage: validate <levels-directory> | preview <levels-directory> <output-directory>");
errors.println("Usage: validate <levels-directory> | validate-matrix <matrix-file> "
+ "<levels-directory> | preview <levels-directory> <output-directory>");
return 2;
} catch (IllegalArgumentException | IOException error) {
errors.println(error.getMessage());
@@ -0,0 +1,256 @@
package com.projectsacrifice.game;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** Immutable release plan for production levels 18-69. */
public final class ProductionContentMatrix {
public static final String HEADER = "number\tworldId\tlevelId\trole\ttitle\tintroducedMechanic"
+ "\tmechanics\ttargetDeaths\ttargetSeconds\tcollectible";
public static final int LEGACY_LEVEL_COUNT = 17;
public static final int LEVELS_PER_WORLD = 13;
private static final List<String> EXPECTED_WORLDS = List.of(
"foundations", "physics", "states", "mastery");
public enum Role { MAIN, CHALLENGE, FINALE }
public static final class Entry {
public final int number;
public final String worldId;
public final String levelId;
public final Role role;
public final String title;
public final String introducedMechanic;
public final List<String> mechanics;
public final int targetDeaths;
public final int targetSeconds;
public final boolean collectible;
private Entry(int number, String worldId, String levelId, Role role, String title,
String introducedMechanic, List<String> mechanics, int targetDeaths,
int targetSeconds, boolean collectible) {
this.number = number;
this.worldId = worldId;
this.levelId = levelId;
this.role = role;
this.title = title;
this.introducedMechanic = introducedMechanic;
this.mechanics = Collections.unmodifiableList(new ArrayList<>(mechanics));
this.targetDeaths = targetDeaths;
this.targetSeconds = targetSeconds;
this.collectible = collectible;
}
}
private final List<Entry> entries;
private ProductionContentMatrix(List<Entry> entries, String source) {
validate(entries, source);
this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
}
public static ProductionContentMatrix load(Reader source, String sourceName) {
if (source == null) throw new IllegalArgumentException("content matrix reader is required");
String name = sourceName == null || sourceName.trim().isEmpty()
? "content matrix" : sourceName;
List<Entry> entries = new ArrayList<>();
try {
BufferedReader reader = source instanceof BufferedReader
? (BufferedReader) source : new BufferedReader(source);
String header = reader.readLine();
if (header != null && header.startsWith("\ufeff")) header = header.substring(1);
if (!HEADER.equals(header)) {
throw new IllegalArgumentException(name + " has an unsupported header");
}
String line;
int lineNumber = 1;
while ((line = reader.readLine()) != null) {
lineNumber++;
if (line.trim().isEmpty() || line.startsWith("#")) continue;
entries.add(parse(line, name, lineNumber));
}
} catch (IOException error) {
throw new IllegalArgumentException(name + " could not be read", error);
}
return new ProductionContentMatrix(entries, name);
}
public List<Entry> entries() {
return entries;
}
public List<String> worldIds() {
return EXPECTED_WORLDS;
}
public List<Entry> entriesForWorld(String worldId) {
List<Entry> result = new ArrayList<>();
for (Entry entry : entries) if (entry.worldId.equals(worldId)) result.add(entry);
return Collections.unmodifiableList(result);
}
public int releaseLevelCount() {
return LEGACY_LEVEL_COUNT + entries.size();
}
/** Ensures every authored production level is the exact leading prefix of this plan. */
public int validateAuthoredPrefix(List<LevelDefinition> authoredLevels, String sourceName) {
if (authoredLevels == null) throw new IllegalArgumentException("authored levels are required");
String source = sourceName == null || sourceName.trim().isEmpty()
? "authored catalog" : sourceName;
int authoredProduction = 0;
for (LevelDefinition level : authoredLevels) {
if (level.number <= LEGACY_LEVEL_COUNT) continue;
int matrixIndex = level.number - LEGACY_LEVEL_COUNT - 1;
if (matrixIndex < 0 || matrixIndex >= entries.size()) {
throw new IllegalArgumentException(source + " level " + level.number
+ " is outside the production matrix");
}
Entry planned = entries.get(matrixIndex);
requireEqual(source, level.number, "level id", planned.levelId, level.levelId);
requireEqual(source, level.number, "world id", planned.worldId, level.worldId);
requireEqual(source, level.number, "title", planned.title, level.title);
if (planned.targetDeaths != level.targetDeaths) {
throw mismatch(source, level.number, "target deaths",
Integer.toString(planned.targetDeaths), Integer.toString(level.targetDeaths));
}
if (planned.targetSeconds != level.targetSeconds) {
throw mismatch(source, level.number, "target seconds",
Integer.toString(planned.targetSeconds), Integer.toString(level.targetSeconds));
}
if (planned.collectible != (level.collectible != null)) {
throw mismatch(source, level.number, "collectible",
Boolean.toString(planned.collectible), Boolean.toString(level.collectible != null));
}
authoredProduction++;
}
return authoredProduction;
}
private static Entry parse(String line, String source, int lineNumber) {
String[] fields = line.split("\\t", -1);
if (fields.length != 10) {
throw new IllegalArgumentException(source + ":" + lineNumber
+ " expected 10 tab-separated fields but found " + fields.length);
}
for (int index = 0; index < fields.length; index++) fields[index] = fields[index].trim();
int number = integer(fields[0], source, lineNumber, "number");
Role role;
try {
role = Role.valueOf(required(fields[3], source, lineNumber, "role"));
} catch (IllegalArgumentException error) {
throw new IllegalArgumentException(source + ":" + lineNumber
+ " has invalid role '" + fields[3] + "'");
}
List<String> mechanics = new ArrayList<>();
for (String mechanic : required(fields[6], source, lineNumber, "mechanics").split(",")) {
String trimmed = mechanic.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException(source + ":" + lineNumber
+ " has an empty mechanic");
}
mechanics.add(trimmed);
}
String collectible = required(fields[9], source, lineNumber, "collectible");
if (!"true".equals(collectible) && !"false".equals(collectible)) {
throw new IllegalArgumentException(source + ":" + lineNumber
+ " has invalid collectible value '" + collectible + "'");
}
return new Entry(number,
required(fields[1], source, lineNumber, "worldId"),
required(fields[2], source, lineNumber, "levelId"), role,
required(fields[4], source, lineNumber, "title"),
required(fields[5], source, lineNumber, "introducedMechanic"), mechanics,
integer(fields[7], source, lineNumber, "targetDeaths"),
integer(fields[8], source, lineNumber, "targetSeconds"),
Boolean.parseBoolean(collectible));
}
private static void validate(List<Entry> entries, String source) {
int expectedSize = EXPECTED_WORLDS.size() * LEVELS_PER_WORLD;
if (entries.size() != expectedSize) {
throw new IllegalArgumentException(source + " expected " + expectedSize
+ " production levels but found " + entries.size());
}
Set<String> ids = new HashSet<>();
for (int index = 0; index < entries.size(); index++) {
Entry entry = entries.get(index);
int expectedNumber = LEGACY_LEVEL_COUNT + index + 1;
if (entry.number != expectedNumber) {
throw new IllegalArgumentException(source + " expected level number "
+ expectedNumber + " but found " + entry.number);
}
String expectedWorld = EXPECTED_WORLDS.get(index / LEVELS_PER_WORLD);
if (!expectedWorld.equals(entry.worldId)) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " expected world '" + expectedWorld + "' but found '" + entry.worldId + "'");
}
if (!ids.add(entry.levelId)) {
throw new IllegalArgumentException(source + " duplicates level id '"
+ entry.levelId + "'");
}
if (!entry.levelId.startsWith(entry.worldId + ".")) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " id must start with '" + entry.worldId + ".'");
}
int worldIndex = index % LEVELS_PER_WORLD;
Role expectedRole = worldIndex == 11 ? Role.CHALLENGE
: worldIndex == 12 ? Role.FINALE : Role.MAIN;
if (entry.role != expectedRole) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " expected role " + expectedRole + " but found " + entry.role);
}
boolean introduces = !"-".equals(entry.introducedMechanic);
if (entry.role != Role.MAIN && introduces) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " cannot introduce a mechanic in role " + entry.role);
}
if (introduces && !entry.mechanics.contains(entry.introducedMechanic)) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " introduced mechanic '" + entry.introducedMechanic
+ "' is absent from mechanics");
}
if (entry.targetDeaths < 0 || entry.targetSeconds <= 0) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " has invalid challenge targets");
}
if (!entry.collectible) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " must define a collectible");
}
}
}
private static String required(String value, String source, int line, String field) {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException(source + ":" + line + " requires " + field);
}
return value;
}
private static int integer(String value, String source, int line, String field) {
try {
return Integer.parseInt(required(value, source, line, field));
} catch (NumberFormatException error) {
throw new IllegalArgumentException(source + ":" + line
+ " has invalid integer for " + field + ": '" + value + "'");
}
}
private static void requireEqual(String source, int number, String field,
String expected, String actual) {
if (!expected.equals(actual)) throw mismatch(source, number, field, expected, actual);
}
private static IllegalArgumentException mismatch(String source, int number, String field,
String expected, String actual) {
return new IllegalArgumentException(source + " level " + number + " " + field
+ " expected '" + expected + "' but found '" + actual + "'");
}
}
@@ -0,0 +1,53 @@
number worldId levelId role title introducedMechanic mechanics targetDeaths targetSeconds collectible
18 foundations foundations.threefold-proof MAIN Threefold Proof challenge-stars movement,jump,challenge-stars 0 30 true
19 foundations foundations.first-return MAIN First Return - movement,jump 0 28 true
20 foundations foundations.covered-path MAIN Covered Path corpse-bridge movement,jump,death,corpse-bridge,spikes 2 42 true
21 foundations foundations.held-open MAIN Held Open pressure movement,jump,death,pressure,door 1 45 true
22 foundations foundations.clockwork-step MAIN Clockwork Step toggle movement,jump,toggle,moving-platform 0 48 true
23 foundations foundations.remote-volunteer MAIN Remote Volunteer saw-launch movement,jump,death,saw-launch,pressure,door 1 50 true
24 foundations foundations.frozen-step MAIN Frozen Step frozen-body movement,jump,death,frozen-body 1 48 true
25 foundations foundations.living-wire MAIN Living Wire conductive-body movement,jump,death,conductive-body,door 1 52 true
26 foundations foundations.borrowed-time MAIN Second Hand timed-switch movement,jump,timed-switch,moving-platform,pit 0 55 true
27 foundations foundations.two-keys MAIN Two Keys signal-and movement,jump,death,pressure,conductive-body,signal-and,door 2 65 true
28 foundations foundations.clean-restart MAIN Clean Restart - movement,jump,death,saw-launch,frozen-body,conductive-body 3 75 true
29 foundations foundations.minimum-cost CHALLENGE Minimum Cost - movement,jump,death,corpse-bridge,pressure,timed-switch 2 65 true
30 foundations foundations.foundation-final FINALE Foundation Relay - movement,jump,death,corpse-bridge,pressure,toggle,timed-switch,moving-platform,door 3 90 true
31 physics physics.mass-basics MAIN Weight Class weighted-object movement,jump,weighted-object,pressure,door 0 45 true
32 physics physics.stack-order MAIN Stack Order object-stacking movement,jump,weighted-object,object-stacking 0 50 true
33 physics physics.launch-window MAIN Launch Window - movement,jump,saw-launch,moving-platform 1 55 true
34 physics physics.crusher-rhythm MAIN Press Cycle crusher movement,jump,death,crusher,pressure 1 55 true
35 physics physics.rolling-signal MAIN Rolling Signal moving-hazard movement,jump,weighted-object,moving-hazard,pressure 0 60 true
36 physics physics.returning-bolt MAIN Returning Bolt projectile movement,jump,death,projectile,pressure 1 60 true
37 physics physics.blast-delivery MAIN Blast Delivery explosion movement,jump,death,explosion,weighted-object 1 65 true
38 physics physics.moving-load MAIN Moving Load - movement,jump,weighted-object,moving-platform,pit 0 65 true
39 physics physics.counter-balance MAIN Counter Balance - movement,jump,weighted-object,object-stacking,signal-and 0 70 true
40 physics physics.pit-ferry MAIN Pit Ferry - movement,jump,death,corpse-bridge,moving-platform,pit 2 75 true
41 physics physics.mass-and-power MAIN Mass and Power - movement,jump,weighted-object,conductive-body,signal-and,door 1 80 true
42 physics physics.no-spare-mass CHALLENGE No Spare Mass - movement,jump,weighted-object,object-stacking,crusher 0 75 true
43 physics physics.physics-final FINALE Chain Reaction - movement,jump,death,weighted-object,projectile,explosion,crusher,moving-platform,door 3 100 true
44 states states.pinned-body MAIN Pinned Lesson pinned-body movement,jump,death,pinned-body,pressure 1 50 true
45 states states.crushed-path MAIN Low Profile crushed-body movement,jump,death,crushed-body,crusher 1 55 true
46 states states.burned-bridge MAIN Ashen Bridge burned-body movement,jump,death,burned-body,fire 2 60 true
47 states states.frozen-weight MAIN Cold Mass - movement,jump,death,frozen-body,weighted-object,pressure 1 60 true
48 states states.conductive-clock MAIN Live Timer - movement,jump,death,conductive-body,timed-switch,door 1 65 true
49 states states.transformed-bridge MAIN Changed Support - movement,jump,death,pinned-body,crushed-body,corpse-bridge 2 70 true
50 states states.frozen-conductor MAIN Cold Circuit - movement,jump,death,frozen-body,conductive-body,signal-and 2 75 true
51 states states.burned-switch MAIN False Weight - movement,jump,death,burned-body,pressure,door 2 75 true
52 states states.crushed-projectile MAIN Low Crossing - movement,jump,death,crushed-body,projectile 1 70 true
53 states states.timed-freeze MAIN Melting Window - movement,jump,death,frozen-body,timed-switch,moving-platform 1 75 true
54 states states.state-sequence MAIN State Sequence - movement,jump,death,pinned-body,crushed-body,burned-body,door 3 90 true
55 states states.state-limit CHALLENGE State Limit - movement,jump,death,frozen-body,conductive-body,burned-body 3 85 true
56 states states.states-final FINALE Matter Final - movement,jump,death,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,timed-switch,door 4 110 true
57 mastery mastery.relay-chain MAIN Relay Chain - movement,jump,death,conductive-body,signal-and,timed-switch,door 2 80 true
58 mastery mastery.moving-window MAIN Moving Window - movement,jump,death,moving-platform,timed-switch,crusher,pit 1 85 true
59 mastery mastery.body-procession MAIN Body Procession - movement,jump,death,frozen-body,conductive-body,corpse-bridge 3 90 true
60 mastery mastery.hazard-gauntlet MAIN Hazard Gauntlet - movement,jump,death,saw-launch,crusher,projectile,fire 3 95 true
61 mastery mastery.weighted-states MAIN Weighted States - movement,jump,death,weighted-object,frozen-body,burned-body,signal-and 2 95 true
62 mastery mastery.restart-proof MAIN Restart Proof - movement,jump,death,corpse-bridge,timed-switch,moving-platform,pit 2 90 true
63 mastery mastery.vertical-procession MAIN Vertical Procession - movement,jump,death,pinned-body,moving-platform,pressure,door 2 100 true
64 mastery mastery.blast-and-freeze MAIN Blast and Freeze - movement,jump,death,explosion,frozen-body,weighted-object 2 95 true
65 mastery mastery.four-signals MAIN Four Signals - movement,jump,death,pressure,toggle,timed-switch,conductive-body,signal-and,door 3 110 true
66 mastery mastery.recovery-master MAIN Recovery Master - movement,jump,death,timed-switch,moving-platform,pit,door 1 100 true
67 mastery mastery.final-rehearsal MAIN Final Rehearsal - movement,jump,death,saw-launch,frozen-body,conductive-body,weighted-object,moving-platform 4 120 true
68 mastery mastery.perfect-procession CHALLENGE Perfect Procession - movement,jump,death,frozen-body,conductive-body,weighted-object,timed-switch,signal-and 3 110 true
69 mastery mastery.mastery-final FINALE Last Reliquary - movement,jump,death,corpse-bridge,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,weighted-object,moving-platform,signal-and,door 5 150 true
1 number worldId levelId role title introducedMechanic mechanics targetDeaths targetSeconds collectible
2 18 foundations foundations.threefold-proof MAIN Threefold Proof challenge-stars movement,jump,challenge-stars 0 30 true
3 19 foundations foundations.first-return MAIN First Return - movement,jump 0 28 true
4 20 foundations foundations.covered-path MAIN Covered Path corpse-bridge movement,jump,death,corpse-bridge,spikes 2 42 true
5 21 foundations foundations.held-open MAIN Held Open pressure movement,jump,death,pressure,door 1 45 true
6 22 foundations foundations.clockwork-step MAIN Clockwork Step toggle movement,jump,toggle,moving-platform 0 48 true
7 23 foundations foundations.remote-volunteer MAIN Remote Volunteer saw-launch movement,jump,death,saw-launch,pressure,door 1 50 true
8 24 foundations foundations.frozen-step MAIN Frozen Step frozen-body movement,jump,death,frozen-body 1 48 true
9 25 foundations foundations.living-wire MAIN Living Wire conductive-body movement,jump,death,conductive-body,door 1 52 true
10 26 foundations foundations.borrowed-time MAIN Second Hand timed-switch movement,jump,timed-switch,moving-platform,pit 0 55 true
11 27 foundations foundations.two-keys MAIN Two Keys signal-and movement,jump,death,pressure,conductive-body,signal-and,door 2 65 true
12 28 foundations foundations.clean-restart MAIN Clean Restart - movement,jump,death,saw-launch,frozen-body,conductive-body 3 75 true
13 29 foundations foundations.minimum-cost CHALLENGE Minimum Cost - movement,jump,death,corpse-bridge,pressure,timed-switch 2 65 true
14 30 foundations foundations.foundation-final FINALE Foundation Relay - movement,jump,death,corpse-bridge,pressure,toggle,timed-switch,moving-platform,door 3 90 true
15 31 physics physics.mass-basics MAIN Weight Class weighted-object movement,jump,weighted-object,pressure,door 0 45 true
16 32 physics physics.stack-order MAIN Stack Order object-stacking movement,jump,weighted-object,object-stacking 0 50 true
17 33 physics physics.launch-window MAIN Launch Window - movement,jump,saw-launch,moving-platform 1 55 true
18 34 physics physics.crusher-rhythm MAIN Press Cycle crusher movement,jump,death,crusher,pressure 1 55 true
19 35 physics physics.rolling-signal MAIN Rolling Signal moving-hazard movement,jump,weighted-object,moving-hazard,pressure 0 60 true
20 36 physics physics.returning-bolt MAIN Returning Bolt projectile movement,jump,death,projectile,pressure 1 60 true
21 37 physics physics.blast-delivery MAIN Blast Delivery explosion movement,jump,death,explosion,weighted-object 1 65 true
22 38 physics physics.moving-load MAIN Moving Load - movement,jump,weighted-object,moving-platform,pit 0 65 true
23 39 physics physics.counter-balance MAIN Counter Balance - movement,jump,weighted-object,object-stacking,signal-and 0 70 true
24 40 physics physics.pit-ferry MAIN Pit Ferry - movement,jump,death,corpse-bridge,moving-platform,pit 2 75 true
25 41 physics physics.mass-and-power MAIN Mass and Power - movement,jump,weighted-object,conductive-body,signal-and,door 1 80 true
26 42 physics physics.no-spare-mass CHALLENGE No Spare Mass - movement,jump,weighted-object,object-stacking,crusher 0 75 true
27 43 physics physics.physics-final FINALE Chain Reaction - movement,jump,death,weighted-object,projectile,explosion,crusher,moving-platform,door 3 100 true
28 44 states states.pinned-body MAIN Pinned Lesson pinned-body movement,jump,death,pinned-body,pressure 1 50 true
29 45 states states.crushed-path MAIN Low Profile crushed-body movement,jump,death,crushed-body,crusher 1 55 true
30 46 states states.burned-bridge MAIN Ashen Bridge burned-body movement,jump,death,burned-body,fire 2 60 true
31 47 states states.frozen-weight MAIN Cold Mass - movement,jump,death,frozen-body,weighted-object,pressure 1 60 true
32 48 states states.conductive-clock MAIN Live Timer - movement,jump,death,conductive-body,timed-switch,door 1 65 true
33 49 states states.transformed-bridge MAIN Changed Support - movement,jump,death,pinned-body,crushed-body,corpse-bridge 2 70 true
34 50 states states.frozen-conductor MAIN Cold Circuit - movement,jump,death,frozen-body,conductive-body,signal-and 2 75 true
35 51 states states.burned-switch MAIN False Weight - movement,jump,death,burned-body,pressure,door 2 75 true
36 52 states states.crushed-projectile MAIN Low Crossing - movement,jump,death,crushed-body,projectile 1 70 true
37 53 states states.timed-freeze MAIN Melting Window - movement,jump,death,frozen-body,timed-switch,moving-platform 1 75 true
38 54 states states.state-sequence MAIN State Sequence - movement,jump,death,pinned-body,crushed-body,burned-body,door 3 90 true
39 55 states states.state-limit CHALLENGE State Limit - movement,jump,death,frozen-body,conductive-body,burned-body 3 85 true
40 56 states states.states-final FINALE Matter Final - movement,jump,death,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,timed-switch,door 4 110 true
41 57 mastery mastery.relay-chain MAIN Relay Chain - movement,jump,death,conductive-body,signal-and,timed-switch,door 2 80 true
42 58 mastery mastery.moving-window MAIN Moving Window - movement,jump,death,moving-platform,timed-switch,crusher,pit 1 85 true
43 59 mastery mastery.body-procession MAIN Body Procession - movement,jump,death,frozen-body,conductive-body,corpse-bridge 3 90 true
44 60 mastery mastery.hazard-gauntlet MAIN Hazard Gauntlet - movement,jump,death,saw-launch,crusher,projectile,fire 3 95 true
45 61 mastery mastery.weighted-states MAIN Weighted States - movement,jump,death,weighted-object,frozen-body,burned-body,signal-and 2 95 true
46 62 mastery mastery.restart-proof MAIN Restart Proof - movement,jump,death,corpse-bridge,timed-switch,moving-platform,pit 2 90 true
47 63 mastery mastery.vertical-procession MAIN Vertical Procession - movement,jump,death,pinned-body,moving-platform,pressure,door 2 100 true
48 64 mastery mastery.blast-and-freeze MAIN Blast and Freeze - movement,jump,death,explosion,frozen-body,weighted-object 2 95 true
49 65 mastery mastery.four-signals MAIN Four Signals - movement,jump,death,pressure,toggle,timed-switch,conductive-body,signal-and,door 3 110 true
50 66 mastery mastery.recovery-master MAIN Recovery Master - movement,jump,death,timed-switch,moving-platform,pit,door 1 100 true
51 67 mastery mastery.final-rehearsal MAIN Final Rehearsal - movement,jump,death,saw-launch,frozen-body,conductive-body,weighted-object,moving-platform 4 120 true
52 68 mastery mastery.perfect-procession CHALLENGE Perfect Procession - movement,jump,death,frozen-body,conductive-body,weighted-object,timed-switch,signal-and 3 110 true
53 69 mastery mastery.mastery-final FINALE Last Reliquary - movement,jump,death,corpse-bridge,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,weighted-object,moving-platform,signal-and,door 5 150 true
@@ -1 +1 @@
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json","level-17.json","level-18.json"]}
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json","level-17.json","level-18.json","level-19.json","level-20.json","level-21.json","level-22.json","level-23.json","level-24.json","level-25.json","level-26.json","level-27.json","level-28.json","level-29.json","level-30.json","level-31.json","level-32.json","level-33.json","level-34.json"]}
@@ -0,0 +1,14 @@
{
"formatVersion":1,"physicsVersion":1,"number":19,
"id":"foundations.first-return","worldId":"foundations",
"copy":{"en":{"title":"First Return","hint":"Read the two ledges, release between jumps, and keep moving toward the relic."},"ru":{"title":"Первое возвращение","hint":"Оцените два уступа, отпускайте прыжок между ними и двигайтесь к реликвии."}},
"world":{"width":2300,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2090,"y":770,"width":86,"height":110},
"collectible":{"x":1500,"y":790,"width":58,"height":58},
"targets":{"deaths":0,"seconds":28},
"platforms":[
{"box":{"x":0,"y":880,"width":2300,"height":200}},
{"box":{"x":650,"y":760,"width":240,"height":34}},
{"box":{"x":1080,"y":690,"width":300,"height":34}}
],"hazards":[],"switches":[],"doors":[]
}
@@ -0,0 +1,18 @@
{
"formatVersion":1,"physicsVersion":1,"number":20,
"id":"foundations.covered-path","worldId":"foundations",
"copy":{"en":{"title":"Covered Path","hint":"Two volunteers can cover the spikes. Place them apart, then cross what remains."},"ru":{"title":"Накрытый путь","hint":"Два добровольца могут накрыть шипы. Разместите их раздельно и перейдите по оставшимся телам."}},
"world":{"width":2300,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2080,"y":770,"width":86,"height":110},
"collectible":{"x":1300,"y":665,"width":58,"height":58},
"targets":{"deaths":2,"seconds":42},
"platforms":[
{"box":{"x":0,"y":880,"width":2300,"height":200}},
{"box":{"x":1240,"y":740,"width":280,"height":34}}
],
"hazards":[
{"type":"SPIKES","box":{"x":700,"y":850,"width":100,"height":30}},
{"type":"SPIKES","box":{"x":930,"y":850,"width":100,"height":30}}
],
"switches":[],"doors":[]
}
@@ -0,0 +1,13 @@
{
"formatVersion":1,"physicsVersion":1,"number":21,
"id":"foundations.held-open","worldId":"foundations",
"copy":{"en":{"title":"Held Open","hint":"Let the saw leave one volunteer on the plate; the next can clear both body and door."},"ru":{"title":"Удержать открытым","hint":"Пусть пила оставит добровольца на плите; следующий перепрыгнет тело и открытую дверь."}},
"world":{"width":2350,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2140,"y":770,"width":86,"height":110},
"collectible":{"x":1160,"y":650,"width":58,"height":58},
"targets":{"deaths":1,"seconds":45},
"platforms":[{"box":{"x":0,"y":880,"width":2350,"height":200}}],
"hazards":[{"type":"SAW","box":{"x":650,"y":760,"width":92,"height":92},"impulseX":0,"impulseY":-150}],
"switches":[{"type":"PRESSURE","box":{"x":625,"y":860,"width":160,"height":20},"signalId":"held-gate","durationSeconds":0,"requiredMass":1}],
"doors":[{"box":{"x":1260,"y":570,"width":78,"height":310},"requiredSignals":["held-gate"]}]
}
@@ -0,0 +1,17 @@
{
"formatVersion":1,"physicsVersion":1,"number":22,
"id":"foundations.clockwork-step","worldId":"foundations",
"copy":{"en":{"title":"Clockwork Step","hint":"Set the brass toggle, board the ferry, and wait for its far-side overlap."},"ru":{"title":"Заводная ступень","hint":"Включите латунный рычаг, встаньте на паром и дождитесь стыковки с дальним берегом."}},
"world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2260,"y":770,"width":86,"height":110},
"collectible":{"x":1435,"y":650,"width":58,"height":58},
"targets":{"deaths":0,"seconds":48},
"platforms":[
{"box":{"x":0,"y":880,"width":1100,"height":200}},
{"box":{"x":1600,"y":880,"width":900,"height":200}},
{"box":{"x":940,"y":770,"width":320,"height":34},"travelX":360,"travelY":0,"periodSeconds":5.0,"controlId":"ferry"}
],
"hazards":[{"type":"PIT","box":{"x":1100,"y":880,"width":500,"height":200}}],
"switches":[{"type":"TOGGLE","box":{"x":760,"y":820,"width":72,"height":60},"signalId":"ferry","durationSeconds":0,"requiredMass":1}],
"doors":[]
}
@@ -0,0 +1,16 @@
{
"formatVersion":1,"physicsVersion":1,"number":23,
"id":"foundations.remote-volunteer","worldId":"foundations",
"copy":{"en":{"title":"Remote Volunteer","hint":"Let the saw deliver one volunteer to the raised plate, then clear the retained body."},"ru":{"title":"Удалённый доброволец","hint":"Пусть пила доставит добровольца на поднятую плиту, затем перепрыгните оставшееся тело."}},
"world":{"width":2450,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2220,"y":770,"width":86,"height":110},
"collectible":{"x":1180,"y":650,"width":58,"height":58},
"targets":{"deaths":1,"seconds":50},
"platforms":[
{"box":{"x":0,"y":880,"width":2450,"height":200}},
{"box":{"x":900,"y":800,"width":400,"height":34}}
],
"hazards":[{"type":"SAW","box":{"x":600,"y":760,"width":96,"height":96},"impulseX":550,"impulseY":-700}],
"switches":[{"type":"PRESSURE","box":{"x":1030,"y":780,"width":170,"height":20},"signalId":"remote-gate","durationSeconds":0,"requiredMass":1}],
"doors":[{"box":{"x":1540,"y":560,"width":82,"height":320},"requiredSignals":["remote-gate"]}]
}
@@ -0,0 +1,16 @@
{
"formatVersion":1,"physicsVersion":1,"number":24,
"id":"foundations.frozen-step","worldId":"foundations",
"copy":{"en":{"title":"Frozen Step","hint":"Freeze one volunteer, stand on the block, and jump into the upper route."},"ru":{"title":"Замёрзшая ступень","hint":"Заморозьте добровольца, встаньте на блок и прыгните на верхний маршрут."}},
"world":{"width":2350,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2110,"y":500,"width":86,"height":110},
"collectible":{"x":1280,"y":500,"width":58,"height":58},
"targets":{"deaths":1,"seconds":48},
"platforms":[
{"box":{"x":0,"y":880,"width":1260,"height":200}},
{"box":{"x":850,"y":590,"width":560,"height":34}},
{"box":{"x":1500,"y":610,"width":700,"height":34}}
],
"hazards":[{"type":"FREEZER","box":{"x":720,"y":776,"width":100,"height":104}}],
"switches":[],"doors":[]
}
@@ -0,0 +1,13 @@
{
"formatVersion":1,"physicsVersion":1,"number":25,
"id":"foundations.living-wire","worldId":"foundations",
"copy":{"en":{"title":"Living Wire","hint":"One conductive body keeps the relay alive. Jump over it to use the open route."},"ru":{"title":"Живой проводник","hint":"Одно проводящее тело удерживает реле. Перепрыгните его и используйте открытый маршрут."}},
"world":{"width":2350,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2130,"y":770,"width":86,"height":110},
"collectible":{"x":1100,"y":790,"width":58,"height":58},
"targets":{"deaths":1,"seconds":52},
"platforms":[{"box":{"x":0,"y":880,"width":2350,"height":200}}],
"hazards":[{"type":"ELECTRIC","box":{"x":720,"y":780,"width":110,"height":100},"relayId":"living-power"}],
"switches":[],
"doors":[{"box":{"x":1350,"y":560,"width":84,"height":320},"requiredSignals":["living-power"]}]
}
@@ -0,0 +1,21 @@
{
"formatVersion":1,"physicsVersion":1,"number":26,
"id":"foundations.borrowed-time","worldId":"foundations",
"copy":{"en":{"title":"Second Hand","hint":"Start the clock, ride across, and use the high far relay if the gate closes."},"ru":{"title":"Секундная стрелка","hint":"Запустите часы, переедьте пропасть и используйте высокое дальнее реле, если ворота закроются."}},
"world":{"width":2700,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2470,"y":600,"width":86,"height":110},
"collectible":{"x":2350,"y":580,"width":58,"height":58},
"targets":{"deaths":0,"seconds":55},
"platforms":[
{"box":{"x":0,"y":880,"width":1100,"height":200}},
{"box":{"x":1580,"y":880,"width":1120,"height":200}},
{"box":{"x":2230,"y":710,"width":470,"height":34}},
{"box":{"x":940,"y":820,"width":360,"height":34},"travelX":380,"travelY":0,"periodSeconds":4.4,"controlId":"second-hand"}
],
"hazards":[{"type":"PIT","box":{"x":1100,"y":880,"width":480,"height":200}}],
"switches":[
{"type":"TIMED","box":{"x":760,"y":810,"width":90,"height":70},"signalId":"second-hand","durationSeconds":9.0,"requiredMass":1},
{"type":"TIMED","box":{"x":1700,"y":650,"width":90,"height":70},"signalId":"second-hand","durationSeconds":4.0,"requiredMass":1}
],
"doors":[{"box":{"x":1920,"y":560,"width":82,"height":320},"requiredSignals":["second-hand"]}]
}
@@ -0,0 +1,16 @@
{
"formatVersion":1,"physicsVersion":1,"number":27,
"id":"foundations.two-keys","worldId":"foundations",
"copy":{"en":{"title":"Two Keys","hint":"Leave one body on the plate and one in the live relay; the door needs both."},"ru":{"title":"Два ключа","hint":"Оставьте одно тело на плите, другое в реле под током: двери нужны оба сигнала."}},
"world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2260,"y":770,"width":86,"height":110},
"collectible":{"x":1320,"y":790,"width":58,"height":58},
"targets":{"deaths":2,"seconds":65},
"platforms":[{"box":{"x":0,"y":880,"width":2500,"height":200}}],
"hazards":[
{"type":"SPIKES","box":{"x":620,"y":850,"width":100,"height":30}},
{"type":"ELECTRIC","box":{"x":1050,"y":780,"width":110,"height":100},"relayId":"wire-key"}
],
"switches":[{"type":"PRESSURE","box":{"x":600,"y":842,"width":140,"height":38},"signalId":"weight-key","durationSeconds":0,"requiredMass":1}],
"doors":[{"box":{"x":1540,"y":560,"width":84,"height":320},"requiredSignals":["weight-key","wire-key"]}]
}
@@ -0,0 +1,19 @@
{
"formatVersion":1,"physicsVersion":1,"number":28,
"id":"foundations.clean-restart","worldId":"foundations",
"copy":{"en":{"title":"Clean Restart","hint":"Build a three-body route: delivered, frozen, then conductive. A restart clears the whole attempt."},"ru":{"title":"Чистый перезапуск","hint":"Постройте путь из трёх тел: доставленного, замёрзшего и проводящего. Перезапуск очистит попытку."}},
"world":{"width":3000,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2760,"y":770,"width":86,"height":110},
"collectible":{"x":2180,"y":790,"width":58,"height":58},
"targets":{"deaths":3,"seconds":75},
"platforms":[
{"box":{"x":0,"y":880,"width":3000,"height":200}},
{"box":{"x":900,"y":800,"width":360,"height":34}}
],
"hazards":[
{"type":"SAW","box":{"x":600,"y":760,"width":96,"height":96},"impulseX":500,"impulseY":-700},
{"type":"FREEZER","box":{"x":1250,"y":776,"width":100,"height":104}},
{"type":"ELECTRIC","box":{"x":1700,"y":780,"width":110,"height":100},"relayId":"restart-wire"}
],
"switches":[],"doors":[]
}
@@ -0,0 +1,19 @@
{
"formatVersion":1,"physicsVersion":1,"number":29,
"id":"foundations.minimum-cost","worldId":"foundations",
"copy":{"en":{"title":"Minimum Cost","hint":"Spend exactly two volunteers on the bridge, keep the far plate held, then start the short clock."},"ru":{"title":"Минимальная цена","hint":"Потратьте ровно двух добровольцев на мост, удержите дальнюю плиту и запустите короткий таймер."}},
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2160,"y":770,"width":86,"height":110},
"collectible":{"x":1360,"y":790,"width":58,"height":58},
"targets":{"deaths":2,"seconds":65},
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
"hazards":[
{"type":"SPIKES","box":{"x":700,"y":850,"width":100,"height":30}},
{"type":"SPIKES","box":{"x":930,"y":850,"width":100,"height":30}}
],
"switches":[
{"type":"PRESSURE","box":{"x":900,"y":842,"width":140,"height":38},"signalId":"minimum-weight","durationSeconds":0,"requiredMass":1},
{"type":"TIMED","box":{"x":1190,"y":810,"width":90,"height":70},"signalId":"minimum-clock","durationSeconds":10.0,"requiredMass":1}
],
"doors":[{"box":{"x":1510,"y":560,"width":84,"height":320},"requiredSignals":["minimum-weight","minimum-clock"]}]
}
@@ -0,0 +1,26 @@
{
"formatVersion":1,"physicsVersion":1,"number":30,
"id":"foundations.foundation-final","worldId":"foundations",
"copy":{"en":{"title":"Foundation Relay","hint":"Bridge twice, leave a third volunteer on the relay, then start both controls and ride."},"ru":{"title":"Эстафета основ","hint":"Дважды постройте мост, оставьте третьего добровольца на реле, затем включите оба управления и езжайте."}},
"world":{"width":3400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":3160,"y":770,"width":86,"height":110},
"collectible":{"x":2450,"y":790,"width":58,"height":58},
"targets":{"deaths":3,"seconds":90},
"platforms":[
{"box":{"x":0,"y":880,"width":1700,"height":200}},
{"box":{"x":2300,"y":880,"width":1100,"height":200}},
{"box":{"x":1570,"y":820,"width":360,"height":34},"travelX":400,"travelY":0,"periodSeconds":4.6,"controlId":"finale-ferry"}
],
"hazards":[
{"type":"SPIKES","box":{"x":550,"y":850,"width":100,"height":30}},
{"type":"SPIKES","box":{"x":800,"y":850,"width":100,"height":30}},
{"type":"SAW","box":{"x":1130,"y":760,"width":96,"height":96},"impulseX":0,"impulseY":-150},
{"type":"PIT","box":{"x":1700,"y":880,"width":600,"height":200}}
],
"switches":[
{"type":"PRESSURE","box":{"x":1100,"y":860,"width":160,"height":20},"signalId":"finale-weight","durationSeconds":0,"requiredMass":1},
{"type":"TOGGLE","box":{"x":1350,"y":810,"width":90,"height":70},"signalId":"finale-ferry","durationSeconds":0,"requiredMass":1},
{"type":"TIMED","box":{"x":1490,"y":810,"width":90,"height":70},"signalId":"finale-clock","durationSeconds":18.0,"requiredMass":1}
],
"doors":[{"box":{"x":2610,"y":560,"width":84,"height":320},"requiredSignals":["finale-weight","finale-clock"]}]
}
@@ -0,0 +1,14 @@
{
"formatVersion":1,"physicsVersion":1,"number":31,
"id":"physics.mass-basics","worldId":"physics",
"copy":{"en":{"title":"Weight Class","hint":"Push the marked counterweight onto the plate; mass, not the player, keeps the door open."},"ru":{"title":"Весовая категория","hint":"Затолкните отмеченный противовес на плиту: дверь удерживает масса, а не игрок."}},
"world":{"width":2300,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2070,"y":770,"width":86,"height":110},
"collectible":{"x":1580,"y":790,"width":58,"height":58},
"targets":{"deaths":0,"seconds":45},
"objects":[{"type":"COUNTERWEIGHT","box":{"x":650,"y":780,"width":90,"height":100},"mass":3}],
"platforms":[{"box":{"x":0,"y":880,"width":2300,"height":200}}],
"hazards":[],
"switches":[{"type":"PRESSURE","box":{"x":940,"y":860,"width":250,"height":20},"signalId":"mass-door","durationSeconds":0,"requiredMass":3}],
"doors":[{"box":{"x":1370,"y":560,"width":84,"height":320},"requiredSignals":["mass-door"]}]
}
@@ -0,0 +1,19 @@
{
"formatVersion":1,"physicsVersion":1,"number":32,
"id":"physics.stack-order","worldId":"physics",
"copy":{"en":{"title":"Stack Order","hint":"Climb the supported object staircase in order, then jump onto the high gallery."},"ru":{"title":"Порядок стопки","hint":"Поднимитесь по опирающимся друг на друга объектам и прыгните на высокую галерею."}},
"world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2260,"y":490,"width":86,"height":110},
"collectible":{"x":900,"y":555,"width":58,"height":58},
"targets":{"deaths":0,"seconds":50},
"objects":[
{"type":"BOX","box":{"x":650,"y":800,"width":160,"height":80},"mass":1},
{"type":"ROCK","box":{"x":700,"y":720,"width":140,"height":80},"mass":2},
{"type":"ICE_BLOCK","box":{"x":760,"y":640,"width":120,"height":80},"mass":2}
],
"platforms":[
{"box":{"x":0,"y":880,"width":1080,"height":200}},
{"box":{"x":1030,"y":600,"width":1470,"height":480}}
],
"hazards":[],"switches":[],"doors":[]
}
@@ -0,0 +1,18 @@
{
"formatVersion":1,"physicsVersion":1,"number":33,
"id":"physics.launch-window","worldId":"physics",
"copy":{"en":{"title":"Launch Window","hint":"Read the saw launch once, clear the delivered body, then board the moving bridge at its near edge."},"ru":{"title":"Окно запуска","hint":"Один раз изучите бросок пилы, перепрыгните доставленное тело и садитесь на мост у ближнего края."}},
"world":{"width":2900,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2660,"y":390,"width":86,"height":110},
"collectible":{"x":2050,"y":410,"width":58,"height":58},
"targets":{"deaths":1,"seconds":55},
"platforms":[
{"box":{"x":0,"y":880,"width":2900,"height":200}},
{"box":{"x":1750,"y":500,"width":1150,"height":34}},
{"box":{"x":1350,"y":790,"width":300,"height":34},"travelX":0,"travelY":-390,"periodSeconds":5.0}
],
"hazards":[
{"type":"SAW","box":{"x":600,"y":760,"width":96,"height":96},"impulseX":700,"impulseY":-420}
],
"switches":[],"doors":[]
}
@@ -0,0 +1,15 @@
{
"formatVersion":1,"physicsVersion":1,"number":34,
"id":"physics.crusher-rhythm","worldId":"physics",
"copy":{"en":{"title":"Press Cycle","hint":"Leave one low body on the plate, read the press cycle, and cross during its full rest."},"ru":{"title":"Цикл пресса","hint":"Оставьте одно низкое тело на плите, прочитайте цикл пресса и пройдите в полный покой."}},
"world":{"width":2450,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2210,"y":770,"width":86,"height":110},
"collectible":{"x":1320,"y":790,"width":58,"height":58},
"targets":{"deaths":1,"seconds":55},
"platforms":[{"box":{"x":0,"y":880,"width":2450,"height":200}}],
"hazards":[{"type":"CRUSHER","box":{"x":680,"y":690,"width":170,"height":190},
"impulseX":40,"impulseY":100,"cycleSeconds":4.5,"activeSeconds":2.4,
"speedX":0,"speedY":0,"visualStyle":"steel-press","audioCue":"crusher-impact"}],
"switches":[{"type":"PRESSURE","box":{"x":650,"y":860,"width":230,"height":20},"signalId":"pressed-mass","durationSeconds":0,"requiredMass":1}],
"doors":[]
}
@@ -13,6 +13,7 @@ import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public final class LevelContentToolTest {
@Rule public final TemporaryFolder temporary = new TemporaryFolder();
@@ -68,6 +69,49 @@ public final class LevelContentToolTest {
assertTrue(coordinates, coordinates.contains("1\tcollectible\t0\tCACHE"));
}
@Test
public void validateMatrixCommandChecksRealPlanAgainstAuthoredPrefix() throws Exception {
Path levels = Paths.get(LevelContentToolTest.class.getResource("/levels/index.json").toURI())
.getParent();
Path matrix = Paths.get(LevelContentToolTest.class.getResource(
"/content/production-content-matrix.tsv").toURI());
ByteArrayOutputStream output = new ByteArrayOutputStream();
ByteArrayOutputStream errors = new ByteArrayOutputStream();
int exitCode = LevelContentTool.run(
new String[]{"validate-matrix", matrix.toString(), levels.toString()},
new PrintStream(output, true, StandardCharsets.UTF_8),
new PrintStream(errors, true, StandardCharsets.UTF_8));
assertEquals(0, exitCode);
assertEquals("Validated production content matrix: worlds=4; planned=52; "
+ "authored=17; releaseTotal=69\n",
output.toString(StandardCharsets.UTF_8));
assertEquals("", errors.toString(StandardCharsets.UTF_8));
}
@Test
public void validateMatrixCommandRejectsAnAuthoredLevelThatDriftsFromThePlan() throws Exception {
Path levels = Paths.get(LevelContentToolTest.class.getResource("/levels/index.json").toURI())
.getParent();
Path sourceMatrix = Paths.get(LevelContentToolTest.class.getResource(
"/content/production-content-matrix.tsv").toURI());
Path changedMatrix = temporary.newFile("changed-matrix.tsv").toPath();
Files.writeString(changedMatrix, Files.readString(sourceMatrix)
.replace("Threefold Proof", "Different Planned Title"));
ByteArrayOutputStream errors = new ByteArrayOutputStream();
int exitCode = LevelContentTool.run(
new String[]{"validate-matrix", changedMatrix.toString(), levels.toString()},
new PrintStream(new ByteArrayOutputStream()),
new PrintStream(errors, true, StandardCharsets.UTF_8));
assertEquals(1, exitCode);
assertTrue(errors.toString(StandardCharsets.UTF_8),
errors.toString(StandardCharsets.UTF_8).contains(
"level 18 title expected 'Different Planned Title' but found 'Threefold Proof'"));
}
private static void writeFixture(Path levels) throws Exception {
Files.writeString(levels.resolve("index.json"),
"{\"formatVersion\":1,\"physicsVersion\":1,\"levels\":[\"level-01.json\"]}");
@@ -0,0 +1,95 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.List;
public final class ProductionContentMatrixTest {
@Test
public void approvedMatrixDefinesFourCompleteWorldsAndReleaseSize() throws Exception {
ProductionContentMatrix matrix = loadApprovedMatrix();
assertEquals(52, matrix.entries().size());
assertEquals(69, matrix.releaseLevelCount());
assertEquals(List.of("foundations", "physics", "states", "mastery"), matrix.worldIds());
for (String worldId : matrix.worldIds()) {
List<ProductionContentMatrix.Entry> world = matrix.entriesForWorld(worldId);
assertEquals(13, world.size());
assertEquals(11, world.stream().filter(entry -> entry.role
== ProductionContentMatrix.Role.MAIN).count());
assertEquals(ProductionContentMatrix.Role.CHALLENGE, world.get(11).role);
assertEquals(ProductionContentMatrix.Role.FINALE, world.get(12).role);
assertTrue(world.stream().allMatch(entry -> entry.collectible));
}
ProductionContentMatrix.Entry first = matrix.entries().get(0);
assertEquals(18, first.number);
assertEquals("foundations.threefold-proof", first.levelId);
assertEquals("Threefold Proof", first.title);
assertEquals(0, first.targetDeaths);
assertEquals(30, first.targetSeconds);
}
@Test
public void duplicateStableIdIsRejected() {
String matrix = minimalValidRows().replace(
"foundations.second\tMAIN\tLevel 2",
"foundations.first\tMAIN\tLevel 2");
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> ProductionContentMatrix.load(new java.io.StringReader(matrix), "duplicate.tsv"));
assertTrue(error.getMessage(), error.getMessage().contains("duplicates level id"));
}
@Test
public void introducedMechanicMustBeUsedByTheLevel() {
String matrix = minimalValidRows().replace(
"corpse-bridge\tmovement,corpse-bridge",
"corpse-bridge\tmovement");
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> ProductionContentMatrix.load(new java.io.StringReader(matrix), "mechanic.tsv"));
assertTrue(error.getMessage(), error.getMessage().contains("introduced mechanic"));
}
private static ProductionContentMatrix loadApprovedMatrix() throws Exception {
try (Reader reader = new InputStreamReader(
ProductionContentMatrixTest.class.getResourceAsStream(
"/content/production-content-matrix.tsv"), StandardCharsets.UTF_8)) {
return ProductionContentMatrix.load(reader, "production-content-matrix.tsv");
}
}
private static String minimalValidRows() {
StringBuilder value = new StringBuilder(ProductionContentMatrix.HEADER).append('\n');
String[] worlds = {"foundations", "physics", "states", "mastery"};
int number = 18;
for (String world : worlds) {
for (int index = 0; index < 13; index++) {
ProductionContentMatrix.Role role = index == 11
? ProductionContentMatrix.Role.CHALLENGE
: index == 12 ? ProductionContentMatrix.Role.FINALE
: ProductionContentMatrix.Role.MAIN;
String slug = index == 0 ? "first" : index == 1 ? "second" : "level-" + (index + 1);
String introduced = index == 1 && "foundations".equals(world) ? "corpse-bridge" : "-";
String mechanics = index == 1 && "foundations".equals(world)
? "movement,corpse-bridge" : "movement";
value.append(number++).append('\t').append(world).append('\t')
.append(world).append('.').append(slug).append('\t').append(role)
.append('\t').append("Level ").append(index + 1).append('\t')
.append(introduced).append('\t').append(mechanics)
.append("\t0\t30\ttrue\n");
}
}
return value.toString();
}
}