Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243da9185e | ||
|
|
f53e068cb1 | ||
|
|
4d8aa28f0a | ||
|
|
b31c2ea720 | ||
|
|
4869649b47 |
+1
-1
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+88
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -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()));
|
||||
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-8
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 дней
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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 этого мира.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 +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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user