Завершить мир Foundations
This commit is contained in:
+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.","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.","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-19T08:24:33Z","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-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.","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:25:30Z","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}
|
||||
|
||||
@@ -133,6 +133,10 @@ 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.
|
||||
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
|
||||
@@ -143,10 +147,11 @@ contract is in `docs/STAGE6_CORPSE_STATES.md`. Weighted-object evidence is in
|
||||
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 latest exact gate is `118 app JVM + 7
|
||||
content-tool JVM / 62 Android` on the twenty-six-level candidate (`62/62`,
|
||||
1063.114 seconds). The verified debug APK SHA-256 is
|
||||
`67510d404cfa0704500cbf36c7845e9ba9fa05e96630079ab02bc35113e946fa`.
|
||||
`docs/STAGE6_FOUNDATIONS_WAVE2.md`; the completed world evidence is in
|
||||
`docs/STAGE6_FOUNDATIONS_WAVE3.md`. The latest exact gate is `119 app JVM + 7
|
||||
content-tool JVM / 66 Android` on the thirty-level candidate (`66/66`,
|
||||
6749.094 seconds). The verified debug APK SHA-256 is
|
||||
`d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315`.
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public final class GameRestartTest {
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
view[0].enableDeterministicAutomationClock();
|
||||
view[0].startLevelForAutomation(10);
|
||||
view[0].setAutomationInput(false, true, false);
|
||||
});
|
||||
@@ -58,13 +59,13 @@ public final class GameRestartTest {
|
||||
GameView view,
|
||||
long timeoutMillis,
|
||||
LevelPlaytestBot.SnapshotCondition condition
|
||||
) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
) {
|
||||
GameView.AutomationSnapshot current = snapshot(instrumentation, view);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
long startedAt = current.elapsedMilliseconds();
|
||||
while (current.elapsedMilliseconds() - startedAt < timeoutMillis) {
|
||||
current = snapshot(instrumentation, view);
|
||||
if (condition.reached(current)) return current;
|
||||
Thread.sleep(32L);
|
||||
instrumentation.runOnMainSync(() -> view.advanceDeterministicAutomation(96L));
|
||||
}
|
||||
throw new AssertionError("Restart setup timed out; level=" + current.levelNumber()
|
||||
+ ", x=" + current.playerX() + ", deaths=" + current.deaths()
|
||||
|
||||
@@ -57,6 +57,10 @@ public final class LevelPaginationTest {
|
||||
assertTrue(visible.contains("Frozen Step"));
|
||||
assertTrue(visible.contains("Living Wire"));
|
||||
assertTrue(visible.contains("Second Hand"));
|
||||
assertTrue(visible.contains("Two Keys"));
|
||||
assertTrue(visible.contains("Clean Restart"));
|
||||
assertTrue(visible.contains("Minimum Cost"));
|
||||
assertTrue(visible.contains("Foundation Relay"));
|
||||
assertFalse(visible.contains("Covered Path"));
|
||||
assertTrue(visible.contains("Page 3 of 3"));
|
||||
});
|
||||
|
||||
+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,232 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Production-input routes for the Foundations combination, challenge and finale levels. */
|
||||
public final class FoundationsFinaleScenarios {
|
||||
private FoundationsFinaleScenarios() {}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentySeven() {
|
||||
return LevelPlaytestBot.Scenario.builder(27, "Level 27 - two keys")
|
||||
.step("Leave the pressure key", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1
|
||||
&& !snapshot.allDoorsOpen()))
|
||||
.step("Wait for the relay volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(2, FoundationsFinaleScenarios::respawned))
|
||||
.step("Stage before the pressure key", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(430f)))
|
||||
.step("Cross the pressure key", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(800f)))
|
||||
.step("Leave the conductive key", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.deaths() == 2
|
||||
&& snapshot.corpseCount() == 2))
|
||||
.step("Prove both keys open the door", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(2, GameView.AutomationSnapshot::allDoorsOpen))
|
||||
.step("Wait for the crossing volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(2, FoundationsFinaleScenarios::respawned))
|
||||
.step("Return to the pressure key", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(430f)))
|
||||
.step("Cross the pressure key again", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(800f)))
|
||||
.step("Stage before the conductive key", 1_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(880f)))
|
||||
.step("Take off before the live relay", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 680f))
|
||||
.step("Clear the live key and collect the cache", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_300f && snapshot.collectibleFound()))
|
||||
.step("Pass the AND door", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(1_700f)))
|
||||
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentyEight() {
|
||||
return LevelPlaytestBot.Scenario.builder(28, "Level 28 - clean restart")
|
||||
.step("Deliver the first body", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1))
|
||||
.step("Wait for the freezing volunteer", 2_000L,
|
||||
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Stage before the delivery saw", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
|
||||
.step("Take off before the delivery saw", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the delivery saw", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
|
||||
.step("Jump over the delivered body", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_150f))
|
||||
.step("Freeze the second body", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 2
|
||||
&& snapshot.corpseCount() == 2))
|
||||
.step("Wait for the conductive volunteer", 2_000L,
|
||||
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Stage before the delivery saw again", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
|
||||
.step("Take off before the delivery saw again", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the delivery saw again", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
|
||||
.step("Clear the delivered body again", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_150f)))
|
||||
.step("Stage before the frozen body", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
|
||||
.step("Clear the frozen body", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_430f)))
|
||||
.step("Create the conductive body", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 3
|
||||
&& snapshot.corpseCount() == 3))
|
||||
.step("Wait for the final volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Stage for the final delivery crossing", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
|
||||
.step("Take off for the final delivery crossing", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the delivery saw for the final time", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 740f))
|
||||
.step("Clear the delivered body for the final time", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_150f)))
|
||||
.step("Return to the frozen body", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
|
||||
.step("Clear the frozen body again", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_430f)))
|
||||
.step("Stage before the conductive body", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_500f)))
|
||||
.step("Jump over the conductive body", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_950f))
|
||||
.step("Collect the cache", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelTwentyNine() {
|
||||
return LevelPlaytestBot.Scenario.builder(29, "Level 29 - minimum cost")
|
||||
.step("Place the near bridge body", 3_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.deaths() == 1))
|
||||
.step("Wait for the second bridge volunteer", 1_500L,
|
||||
LevelPlaytestBot.Input.none(), atMost(2, FoundationsFinaleScenarios::respawned))
|
||||
.step("Approach the first bridge body", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Place the far pressure body", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, snapshot -> snapshot.deaths() == 2
|
||||
&& snapshot.corpseCount() == 2
|
||||
&& !snapshot.allDoorsOpen()))
|
||||
.step("Wait for the minimum-cost volunteer", 1_500L,
|
||||
LevelPlaytestBot.Input.none(), atMost(2, FoundationsFinaleScenarios::respawned))
|
||||
.step("Return to the corpse bridge", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Cross both retained bodies", 3_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(1_050f)))
|
||||
.step("Start the minimum clock", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, snapshot -> snapshot.allDoorsOpen()
|
||||
&& snapshot.collectibleFound()))
|
||||
.step("Pass the timed AND door", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, LevelPlaytestBot.groundedAliveAtOrBeyond(1_700f)))
|
||||
.step("Reach the relic", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(2, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static LevelPlaytestBot.Scenario levelThirty() {
|
||||
return LevelPlaytestBot.Scenario.builder(30, "Level 30 - foundation relay")
|
||||
.step("Place the near finale bridge body", 3_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 1))
|
||||
.step("Wait for the far bridge volunteer", 1_500L,
|
||||
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Approach the near finale body", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(400f)))
|
||||
.step("Place the far finale bridge body", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 2))
|
||||
.step("Wait for the pressure volunteer", 1_500L,
|
||||
LevelPlaytestBot.Input.none(), atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Return to the finale bridge", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(400f)))
|
||||
.step("Cross both finale bridge bodies", 3_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
|
||||
.step("Leave the third body on the relay", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.deaths() == 3
|
||||
&& snapshot.corpseCount() == 3
|
||||
&& !snapshot.allDoorsOpen()))
|
||||
.step("Wait for the relay runner", 1_500L, LevelPlaytestBot.Input.none(),
|
||||
atMost(3, FoundationsFinaleScenarios::respawned))
|
||||
.step("Return to the finale bridge again", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(400f)))
|
||||
.step("Cross the complete corpse bridge", 3_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(1_000f)))
|
||||
.step("Take off before the pressure relay", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround() && snapshot.playerY() <= 620f))
|
||||
.step("Clear the pressure relay body", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_300f))
|
||||
.step("Start both finale controls", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.allDoorsOpen()
|
||||
&& snapshot.firstMovingPlatformPowered()))
|
||||
.step("Wait for the finale boarding phase", 3_500L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
atMost(3, snapshot -> MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot, 1_650f)))
|
||||
.step("Board the finale ferry", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerOnMovingPlatform()))
|
||||
.step("Ride the finale ferry", 5_000L, LevelPlaytestBot.Input.none(),
|
||||
atMost(3, snapshot -> MovingPlatformLaunchPolicy.ready(snapshot, 1_950f)))
|
||||
.step("Jump to the far finale floor", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(2_320f)))
|
||||
.step("Collect the finale cache", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, snapshot -> snapshot.playerAlive() && snapshot.collectibleFound()))
|
||||
.step("Pass the finale door", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
atMost(3, LevelPlaytestBot.groundedAliveAtOrBeyond(2_800f)))
|
||||
.step("Reach the Foundations relic", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
atMost(3, GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static LevelPlaytestBot.SnapshotCondition atMost(
|
||||
int deaths, LevelPlaytestBot.SnapshotCondition condition) {
|
||||
return LevelPlaytestBot.whileDeathsAtMost(deaths, condition);
|
||||
}
|
||||
|
||||
private static boolean respawned(GameView.AutomationSnapshot snapshot) {
|
||||
return snapshot.playerAlive() && snapshot.playerX() < 250f;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public final class FoundationsAdvancedContentTest {
|
||||
@Test
|
||||
public void secondFoundationsWaveMatchesMatrixAndPreservesRecoveryContracts() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertEquals(26, levels.size());
|
||||
assertTrue(levels.size() >= 26);
|
||||
|
||||
LevelDefinition remote = levels.get(22);
|
||||
assertIdentity(remote, 23, "foundations.remote-volunteer", "Remote Volunteer", 1, 50);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class FoundationsFinaleContentTest {
|
||||
@Test
|
||||
public void finalFoundationsWaveMatchesTheApprovedMatrixAndClosesTheWorld() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertEquals(30, levels.size());
|
||||
|
||||
LevelDefinition keys = levels.get(26);
|
||||
assertIdentity(keys, 27, "foundations.two-keys", "Two Keys", 2, 65);
|
||||
assertTrue(keys.containsMechanic(LevelDefinition.HazardType.ELECTRIC));
|
||||
assertTrue(keys.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
assertFalse(keys.doors.isEmpty());
|
||||
assertEquals(2, keys.doors.get(0).requiredSignals.size());
|
||||
|
||||
LevelDefinition restart = levels.get(27);
|
||||
assertIdentity(restart, 28, "foundations.clean-restart", "Clean Restart", 3, 75);
|
||||
assertTrue(restart.containsMechanic(LevelDefinition.HazardType.SAW));
|
||||
assertTrue(restart.containsMechanic(LevelDefinition.HazardType.FREEZER));
|
||||
assertTrue(restart.containsMechanic(LevelDefinition.HazardType.ELECTRIC));
|
||||
|
||||
LevelDefinition challenge = levels.get(28);
|
||||
assertIdentity(challenge, 29, "foundations.minimum-cost", "Minimum Cost", 2, 65);
|
||||
assertTrue(challenge.containsMechanic(LevelDefinition.HazardType.SPIKES));
|
||||
assertTrue(challenge.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
assertTrue(challenge.containsMechanic(LevelDefinition.SwitchType.TIMED));
|
||||
assertFalse(challenge.doors.isEmpty());
|
||||
|
||||
LevelDefinition finale = levels.get(29);
|
||||
assertIdentity(finale, 30, "foundations.foundation-final", "Foundation Relay", 3, 90);
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.HazardType.SPIKES));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.HazardType.SAW));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.HazardType.PIT));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.SwitchType.TOGGLE));
|
||||
assertTrue(finale.containsMechanic(LevelDefinition.SwitchType.TIMED));
|
||||
assertTrue(finale.hasMovingPlatform());
|
||||
assertFalse(finale.doors.isEmpty());
|
||||
assertEquals(2, finale.doors.get(0).requiredSignals.size());
|
||||
}
|
||||
|
||||
private static void assertIdentity(LevelDefinition level, int number, String id, String title,
|
||||
int targetDeaths, int targetSeconds) {
|
||||
assertEquals(number, level.number);
|
||||
assertEquals(id, level.levelId);
|
||||
assertEquals("foundations", level.worldId);
|
||||
assertEquals(title, level.title);
|
||||
assertEquals(targetDeaths, level.targetDeaths);
|
||||
assertEquals(targetSeconds, level.targetSeconds);
|
||||
assertNotNull(level.collectible);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public final class LevelCatalogTest {
|
||||
@Test
|
||||
public void productionCatalogContainsMechanicsAndChallengeTeachingLevels() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertEquals(26, levels.size());
|
||||
assertEquals(30, levels.size());
|
||||
Set<String> titles = new HashSet<>();
|
||||
for (int i = 0; i < levels.size(); i++) {
|
||||
LevelDefinition level = levels.get(i);
|
||||
|
||||
@@ -223,8 +223,14 @@ conductive relay и timed ferry с дальним recovery relay имеют solu
|
||||
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`; следующий slice — Levels 27–30, включая Foundations
|
||||
challenge и finale.
|
||||
`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.
|
||||
|
||||
### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
|
||||
|
||||
|
||||
@@ -607,6 +607,82 @@ LevelID="foundations.borrowed-time", WorldID="foundations"
|
||||
обычным беговым маршрутом. Отдельный тест сначала наблюдает реально закрытую 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 наблюдаются ботом.
|
||||
|
||||
## Проверки, следующие из графов
|
||||
|
||||
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
|
||||
|
||||
@@ -49,7 +49,7 @@ finale не вводят новую механику: они проверяют
|
||||
Android `preBuild` зависит от этого gate. Текущее ожидаемое сообщение:
|
||||
|
||||
```text
|
||||
Validated production content matrix: worlds=4; planned=52; authored=9; releaseTotal=69
|
||||
Validated production content matrix: worlds=4; planned=52; authored=13; releaseTotal=69
|
||||
```
|
||||
|
||||
## Content pipeline и Level DoD
|
||||
@@ -64,9 +64,9 @@ JSON-уровень добавляется только ведущим преф
|
||||
5. restart, blocker-softlock и collectible/challenge проверки;
|
||||
6. camera, sound, art, optimization и QA regression gates из Level DoD.
|
||||
|
||||
Foundations 19–26 уже авторизованы через этот pipeline. Следующий bounded slice —
|
||||
Levels 27–30, включая challenge и finale мира; незавершённый уровень не
|
||||
скрывается в общем счётчике production-контента.
|
||||
Foundations 18–30 полностью авторизованы через этот pipeline. Следующий bounded
|
||||
slice — Physics 31–34; незавершённый уровень не скрывается в общем счётчике
|
||||
production-контента.
|
||||
|
||||
## TDD и текущий gate
|
||||
|
||||
@@ -75,19 +75,19 @@ errors), затем отсутствующую CLI-команду `validate-matr
|
||||
добавлен отрицательный regression: изменение planned title Level 18 отклоняется
|
||||
как drift реального JSON-префикса.
|
||||
|
||||
Текущий gate после второго Foundations wave на 19 августа 2026 года:
|
||||
Текущий gate после завершения мира Foundations на 19 августа 2026 года:
|
||||
|
||||
- `ProductionContentMatrixTest` + `LevelContentToolTest`: `7/7`;
|
||||
- app JVM regression: `118/118`;
|
||||
- `validateLevels`: `26` authored levels;
|
||||
- `validateContentMatrix`: `4` worlds, `52` planned, `9` authored production rows,
|
||||
- app JVM regression: `119/119`;
|
||||
- `validateLevels`: `30` authored levels;
|
||||
- `validateContentMatrix`: `4` worlds, `52` planned, `13` authored production rows,
|
||||
release total `69`;
|
||||
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN;
|
||||
- APK ZIP integrity: no errors; `26` level JSON присутствуют в package;
|
||||
- cold unfiltered Android: `62/62`, failures/errors `0/0`, `1063.114 s`;
|
||||
- APK ZIP integrity: no errors; `30` level JSON присутствуют в package;
|
||||
- cold unfiltered Android: `66/66`, failures/errors/skipped `0/0/0`, `6749.094 s`;
|
||||
- debug APK SHA-256:
|
||||
`67510d404cfa0704500cbf36c7845e9ba9fa05e96630079ab02bc35113e946fa`.
|
||||
`d47ba3baffcaf3c946b77965472b8eba26e77b535e35d1dd1ca67211950e5315`.
|
||||
|
||||
Матрица не подменяет device evidence: Levels 19–26 прошли отдельные routes и
|
||||
полный unfiltered gate; детали — `STAGE6_FOUNDATIONS_WAVE1.md` и
|
||||
`STAGE6_FOUNDATIONS_WAVE2.md`.
|
||||
Матрица не подменяет device evidence: Foundations 18–30 прошёл отдельные routes
|
||||
и полный unfiltered gate; детали — `STAGE6_FOUNDATIONS_WAVE1.md`,
|
||||
`STAGE6_FOUNDATIONS_WAVE2.md` и `STAGE6_FOUNDATIONS_WAVE3.md`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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","level-19.json","level-20.json","level-21.json","level-22.json","level-23.json","level-24.json","level-25.json","level-26.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"]}
|
||||
|
||||
@@ -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"]}]
|
||||
}
|
||||
@@ -85,7 +85,7 @@ public final class LevelContentToolTest {
|
||||
|
||||
assertEquals(0, exitCode);
|
||||
assertEquals("Validated production content matrix: worlds=4; planned=52; "
|
||||
+ "authored=9; releaseTotal=69\n",
|
||||
+ "authored=13; releaseTotal=69\n",
|
||||
output.toString(StandardCharsets.UTF_8));
|
||||
assertEquals("", errors.toString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user