Compare commits

...
2 Commits
24 changed files with 1162 additions and 10 deletions
+1 -1
View File
@@ -67,7 +67,7 @@
{"_type":"issue","id":"goon-game-analytics-contract","title":"Схема gameplay analytics","description":"Версионировать level_start/restart/death/complete/abandon/challenge и heatmap payload.","acceptance_criteria":"События документированы, минимальны и проверены на тестовом backend.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:36Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:35Z","metadata":{"stages":7,"primary_stage":7},"labels":["analytics","primary-stage-7","production","stage-7"],"dependencies":[{"issue_id":"goon-game-analytics-contract","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-modular-characters","title":"Модульные персонажи и косметика","description":"Body/helmet/face/weapon/cape/accessory, случайные имена и 30+ косметических элементов.","acceptance_criteria":"Новые жертвы визуально различаются, комбинации не ломают анимации.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2880,"created_at":"2026-08-17T21:40:21Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["art","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-modular-characters","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-challenges","title":"Challenges, collectibles и статистика","description":"Реализовать casualty/time/collectible цели и отображение трёх звёзд.","acceptance_criteria":"Расчёт покрыт unit-тестами и сохраняется по LevelID.","notes":"2026-08-19: claimed after weighted-object slice 259fcd3 passed 108 app JVM + 2 content-tool JVM and exact Android 48/48. TDD scope: stable LevelID/WorldID progression keys with legacy numeric migration, casualty/time/collectible challenge evaluation, persistent best three-star record, data-driven optional collectible, level-select/result rendering, automation and one teaching route without weakening existing gates.\nFinal post-review gate: duplicate stable LevelID now fails catalog load before progress-key collision (targeted RED then GREEN). Clean 116 app JVM + 2 content-tool JVM; final cold host-GPU AVD exact Android 52/52 in 551.773s. Verified APK SHA-256 0b70a0a1a508425c311d7de473318e3bb83407f5f27e9dd4a8e1876b2eb25344.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T06:24:46Z","started_at":"2026-08-19T04:41:26Z","closed_at":"2026-08-19T05:58:26Z","close_reason":"Accepted stable LevelID/WorldID challenge progression: progress v3/session v5 migrations, casualty/time/collectible stars, level 18 3/3 route, 115+2 JVM and exact Android 52/52 (553.686s), APK SHA-256 9c12cfbb15e313421d58f891d9d107af14c9e8e626228fa8fd0c74e56c293d14.","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","progression","stage-6"],"dependencies":[{"issue_id":"goon-game-challenges","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","notes":"2026-08-19: executable matrix commit 4869649 fixed release at 69 levels: workshop 1-17 plus four 13-level production worlds (52 rows). Foundations wave 1 now authors Levels 19-22; matrix prefix is 5/52 including Level 18. TDD/device REDs fixed only in new content: inaccessible L19 cache, skipped second L20 body, late L21 saw takeoff, early-moving L22 ferry; pagination acceptance updated from 2 to 3 pages. Four route methods passed 12/12 playthroughs, restart DoD passed 1/1. Clean gate: 117/117 app JVM, 7/7 level-format, validators 22/authored5, lint/app/test APK; final cold unfiltered Android 57/57 in 1807.264s, APK SHA 81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef. Next bounded slice is Foundations 23-26; issue remains in_progress until Level 69.","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-19T07:50:26Z","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}
+12 -4
View File
@@ -125,15 +125,23 @@ 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.
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`. The latest exact gate is `117 app JVM + 7
content-tool JVM / 57 Android` on the twenty-two-level candidate (`57/57`,
1807.264 seconds). The verified debug APK SHA-256 is
`81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef`.
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with:
+1
View File
@@ -47,4 +47,5 @@ dependencies {
tasks.named('preBuild').configure {
dependsOn ':level-format:validateLevels'
dependsOn ':level-format:validateContentMatrix'
}
@@ -0,0 +1,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];
}
}
@@ -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(3, view.levelPageCountForAutomation());
assertTrue(view.visibleLocalizedTextForAutomation().contains("The Volunteer"));
assertFalse(view.visibleLocalizedTextForAutomation().contains("Steam Charge"));
});
@@ -39,10 +39,27 @@ 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 3"));
});
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"));
assertFalse(visible.contains("Covered Path"));
assertTrue(visible.contains("Page 3 of 3"));
});
tap(activity, 190f, 965f);
activity.onActivity(current ->
assertEquals(1, current.gameViewForAutomation().levelPageIndexForAutomation()));
tap(activity, 190f, 965f);
activity.onActivity(current ->
assertEquals(0, current.gameViewForAutomation().levelPageIndexForAutomation()));
@@ -0,0 +1,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;
}
}
@@ -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();
assertEquals(22, levels.size());
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);
}
}
@@ -15,7 +15,7 @@ public final class LevelCatalogTest {
@Test
public void productionCatalogContainsMechanicsAndChallengeTeachingLevels() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(18, levels.size());
assertEquals(22, levels.size());
Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i);
+10
View File
@@ -208,6 +208,16 @@ 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`; следующий slice — Levels 23–26.
### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
+73
View File
@@ -462,6 +462,79 @@ 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 ждёт
реальную поддержку, а не единичное пересечение в воздухе.
## Проверки, следующие из графов
1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8.
+92
View File
@@ -0,0 +1,92 @@
# 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=5; 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.
Следующий authoring wave — Foundations 19–30. Уровни добавляются небольшими
вертикальными срезами; незавершённый уровень не скрывается в общем счётчике
production-контента.
## TDD и gate первого среза
RED сначала зафиксировал отсутствующий `ProductionContentMatrix` (15 compile
errors), затем отсутствующую CLI-команду `validate-matrix` (exit `2`). После GREEN
добавлен отрицательный regression: изменение planned title Level 18 отклоняется
как drift реального JSON-префикса.
Текущий gate после первого Foundations wave на 19 августа 2026 года:
- `ProductionContentMatrixTest` + `LevelContentToolTest`: `7/7`;
- app JVM regression: `117/117`;
- `validateLevels`: `22` authored levels;
- `validateContentMatrix`: `4` worlds, `52` planned, `5` authored production rows,
release total `69`;
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN;
- APK ZIP integrity: no errors; matrix и `level-18.json` присутствуют в package;
- cold unfiltered Android: `57/57`, `1807.264 s`;
- debug APK SHA-256:
`81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef`.
Матрица не подменяет device evidence: Levels 19–22 прошли отдельные routes,
restart DoD и полный unfiltered gate; детали — `STAGE6_FOUNDATIONS_WAVE1.md`.
+56
View File
@@ -0,0 +1,56 @@
# Stage 6: Foundations wave 1
## Принятый результат
Первый authoring wave расширяет каталог с 18 до 22 уровней и реализует четыре
последовательные строки executable content matrix:
- 19 `First Return` — два раздельных jump edge, `0` смертей;
- 20 `Covered Path` — две spike-зоны, ровно `2` тела-моста;
- 21 `Held Open` — одно тело удерживает pressure Door;
- 22 `Clockwork Step` — toggle включает горизонтальную ferry platform над PIT.
Каждый уровень имеет RU/EN copy, stable LevelID/WorldID, casualty/time targets,
collectible, solution graph и state-driven production-input route. Четыре route
methods выполняют по три последовательных запуска без teleport, прямой мутации
completion или retry masking.
## TDD и найденные ошибки
RED контракты сначала зафиксировали каталог `18 != 22` и отсутствующие четыре
`FoundationsScenarios`. Первый device run затем дал четыре независимых RED:
1. Level 19 прошёл мимо слишком высокого cache (`x=1949.1761`, deaths `0`).
Cache перенесён на безопасную дальнюю прямую после двух обязательных прыжков.
2. Level 20 одним прыжком пропускал требуемое второе тело (`deaths=1`,
firstCorpseX `641.8331`). Один широкий strip разделён на две spike-зоны.
3. Level 21 повторно касался дальнего края saw (`x=734.3325`, deaths `2`).
Takeoff зафиксирован в рассчитанном окне `x≈470`; physics/saw не менялись.
4. Level 22 терял ferry до стабильной посадки (`x=1215.3331`, deaths `1`).
Toggle перенесён к launch zone, ferry получила достаточный стартовый overlap,
а bot подтверждает `playerOnGround && playerOnMovingPlatform`.
После GREEN отдельный restart test доказал, что user restart Level 20 удаляет
тело/смерть/held input, а Level 22 сбрасывает toggle, platform motion,
collectible и input. Первый unfiltered run нашёл один acceptance drift: каталог
теперь имеет три страницы, а тест ожидал две. UI-контракт обновлён на страницы
1–3 и повторный cold run прошёл полностью.
## Level DoD evidence
- JSON validator: `22` levels, format/physics version `1`;
- content matrix: `4` worlds, `52` planned, `5` authored production rows,
release total `69`;
- focused routes: `4/4` methods, `12` playthroughs, `8.372 s`;
- focused restart: `1/1`;
- app JVM: `117/117`; level-format JVM: `7/7`;
- clean `lintDebug`, app APK и AndroidTest APK: GREEN;
- final cold unfiltered Android 16: `57/57`, failures/errors `0/0`,
instrumentation time `1807.264 s` (`BUILD SUCCESSFUL` in `30m21s`);
- APK ZIP: no errors; `22` level JSON packaged;
- debug APK SHA-256:
`81e50c2b3247a2bf7fad2a2efc461c1d6f19d63e1b10a958e8c1a8544adf48ef`.
Процедурные visual/audio cues и camera bounds используют принятый общий Canvas
pipeline; wave не меняет physics/runtime systems. Следующий bounded authoring
slice — Foundations 23–26.
+16
View File
@@ -12,6 +12,8 @@ dependencies {
}
def levelsDirectory = layout.projectDirectory.dir('src/main/resources/levels')
def contentMatrixFile = layout.projectDirectory.file(
'src/main/resources/content/production-content-matrix.tsv')
def configuredLevelsDirectory = providers.gradleProperty('levelDirectory')
.map { file(it) }
.orElse(levelsDirectory.asFile)
@@ -28,6 +30,20 @@ tasks.register('validateLevels', JavaExec) {
}
}
tasks.register('validateContentMatrix', JavaExec) {
group = 'verification'
description = 'Validates the four-world production plan and its authored level prefix.'
dependsOn classes
classpath = sourceSets.main.runtimeClasspath
mainClass = 'com.projectsacrifice.game.LevelContentTool'
inputs.file contentMatrixFile
inputs.dir configuredLevelsDirectory
doFirst {
setArgs(['validate-matrix', contentMatrixFile.asFile.absolutePath,
configuredLevelsDirectory.get().absolutePath])
}
}
tasks.register('previewLevels', JavaExec) {
group = 'verification'
description = 'Exports deterministic SVG and TSV previews for every level.'
@@ -28,6 +28,19 @@ public final class LevelContentTool {
+ LevelFormat.CURRENT_PHYSICS_VERSION);
return 0;
}
if (args != null && args.length == 3 && "validate-matrix".equals(args[0])) {
Path matrixPath = Paths.get(args[1]).toAbsolutePath().normalize();
ProductionContentMatrix matrix;
try (Reader reader = Files.newBufferedReader(matrixPath, StandardCharsets.UTF_8)) {
matrix = ProductionContentMatrix.load(reader, matrixPath.toString());
}
int authored = matrix.validateAuthoredPrefix(loadDirectory(args[2]), args[2]);
output.println("Validated production content matrix: worlds="
+ matrix.worldIds().size() + "; planned=" + matrix.entries().size()
+ "; authored=" + authored + "; releaseTotal="
+ matrix.releaseLevelCount());
return 0;
}
if (args != null && args.length == 3 && "preview".equals(args[0])) {
List<LevelDefinition> levels = loadDirectory(args[1]);
Path destination = Paths.get(args[2]).toAbsolutePath().normalize();
@@ -35,7 +48,8 @@ public final class LevelContentTool {
output.println("Exported " + levels.size() + " level preview(s) to " + destination);
return 0;
}
errors.println("Usage: validate <levels-directory> | preview <levels-directory> <output-directory>");
errors.println("Usage: validate <levels-directory> | validate-matrix <matrix-file> "
+ "<levels-directory> | preview <levels-directory> <output-directory>");
return 2;
} catch (IllegalArgumentException | IOException error) {
errors.println(error.getMessage());
@@ -0,0 +1,256 @@
package com.projectsacrifice.game;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** Immutable release plan for production levels 18-69. */
public final class ProductionContentMatrix {
public static final String HEADER = "number\tworldId\tlevelId\trole\ttitle\tintroducedMechanic"
+ "\tmechanics\ttargetDeaths\ttargetSeconds\tcollectible";
public static final int LEGACY_LEVEL_COUNT = 17;
public static final int LEVELS_PER_WORLD = 13;
private static final List<String> EXPECTED_WORLDS = List.of(
"foundations", "physics", "states", "mastery");
public enum Role { MAIN, CHALLENGE, FINALE }
public static final class Entry {
public final int number;
public final String worldId;
public final String levelId;
public final Role role;
public final String title;
public final String introducedMechanic;
public final List<String> mechanics;
public final int targetDeaths;
public final int targetSeconds;
public final boolean collectible;
private Entry(int number, String worldId, String levelId, Role role, String title,
String introducedMechanic, List<String> mechanics, int targetDeaths,
int targetSeconds, boolean collectible) {
this.number = number;
this.worldId = worldId;
this.levelId = levelId;
this.role = role;
this.title = title;
this.introducedMechanic = introducedMechanic;
this.mechanics = Collections.unmodifiableList(new ArrayList<>(mechanics));
this.targetDeaths = targetDeaths;
this.targetSeconds = targetSeconds;
this.collectible = collectible;
}
}
private final List<Entry> entries;
private ProductionContentMatrix(List<Entry> entries, String source) {
validate(entries, source);
this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
}
public static ProductionContentMatrix load(Reader source, String sourceName) {
if (source == null) throw new IllegalArgumentException("content matrix reader is required");
String name = sourceName == null || sourceName.trim().isEmpty()
? "content matrix" : sourceName;
List<Entry> entries = new ArrayList<>();
try {
BufferedReader reader = source instanceof BufferedReader
? (BufferedReader) source : new BufferedReader(source);
String header = reader.readLine();
if (header != null && header.startsWith("\ufeff")) header = header.substring(1);
if (!HEADER.equals(header)) {
throw new IllegalArgumentException(name + " has an unsupported header");
}
String line;
int lineNumber = 1;
while ((line = reader.readLine()) != null) {
lineNumber++;
if (line.trim().isEmpty() || line.startsWith("#")) continue;
entries.add(parse(line, name, lineNumber));
}
} catch (IOException error) {
throw new IllegalArgumentException(name + " could not be read", error);
}
return new ProductionContentMatrix(entries, name);
}
public List<Entry> entries() {
return entries;
}
public List<String> worldIds() {
return EXPECTED_WORLDS;
}
public List<Entry> entriesForWorld(String worldId) {
List<Entry> result = new ArrayList<>();
for (Entry entry : entries) if (entry.worldId.equals(worldId)) result.add(entry);
return Collections.unmodifiableList(result);
}
public int releaseLevelCount() {
return LEGACY_LEVEL_COUNT + entries.size();
}
/** Ensures every authored production level is the exact leading prefix of this plan. */
public int validateAuthoredPrefix(List<LevelDefinition> authoredLevels, String sourceName) {
if (authoredLevels == null) throw new IllegalArgumentException("authored levels are required");
String source = sourceName == null || sourceName.trim().isEmpty()
? "authored catalog" : sourceName;
int authoredProduction = 0;
for (LevelDefinition level : authoredLevels) {
if (level.number <= LEGACY_LEVEL_COUNT) continue;
int matrixIndex = level.number - LEGACY_LEVEL_COUNT - 1;
if (matrixIndex < 0 || matrixIndex >= entries.size()) {
throw new IllegalArgumentException(source + " level " + level.number
+ " is outside the production matrix");
}
Entry planned = entries.get(matrixIndex);
requireEqual(source, level.number, "level id", planned.levelId, level.levelId);
requireEqual(source, level.number, "world id", planned.worldId, level.worldId);
requireEqual(source, level.number, "title", planned.title, level.title);
if (planned.targetDeaths != level.targetDeaths) {
throw mismatch(source, level.number, "target deaths",
Integer.toString(planned.targetDeaths), Integer.toString(level.targetDeaths));
}
if (planned.targetSeconds != level.targetSeconds) {
throw mismatch(source, level.number, "target seconds",
Integer.toString(planned.targetSeconds), Integer.toString(level.targetSeconds));
}
if (planned.collectible != (level.collectible != null)) {
throw mismatch(source, level.number, "collectible",
Boolean.toString(planned.collectible), Boolean.toString(level.collectible != null));
}
authoredProduction++;
}
return authoredProduction;
}
private static Entry parse(String line, String source, int lineNumber) {
String[] fields = line.split("\\t", -1);
if (fields.length != 10) {
throw new IllegalArgumentException(source + ":" + lineNumber
+ " expected 10 tab-separated fields but found " + fields.length);
}
for (int index = 0; index < fields.length; index++) fields[index] = fields[index].trim();
int number = integer(fields[0], source, lineNumber, "number");
Role role;
try {
role = Role.valueOf(required(fields[3], source, lineNumber, "role"));
} catch (IllegalArgumentException error) {
throw new IllegalArgumentException(source + ":" + lineNumber
+ " has invalid role '" + fields[3] + "'");
}
List<String> mechanics = new ArrayList<>();
for (String mechanic : required(fields[6], source, lineNumber, "mechanics").split(",")) {
String trimmed = mechanic.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException(source + ":" + lineNumber
+ " has an empty mechanic");
}
mechanics.add(trimmed);
}
String collectible = required(fields[9], source, lineNumber, "collectible");
if (!"true".equals(collectible) && !"false".equals(collectible)) {
throw new IllegalArgumentException(source + ":" + lineNumber
+ " has invalid collectible value '" + collectible + "'");
}
return new Entry(number,
required(fields[1], source, lineNumber, "worldId"),
required(fields[2], source, lineNumber, "levelId"), role,
required(fields[4], source, lineNumber, "title"),
required(fields[5], source, lineNumber, "introducedMechanic"), mechanics,
integer(fields[7], source, lineNumber, "targetDeaths"),
integer(fields[8], source, lineNumber, "targetSeconds"),
Boolean.parseBoolean(collectible));
}
private static void validate(List<Entry> entries, String source) {
int expectedSize = EXPECTED_WORLDS.size() * LEVELS_PER_WORLD;
if (entries.size() != expectedSize) {
throw new IllegalArgumentException(source + " expected " + expectedSize
+ " production levels but found " + entries.size());
}
Set<String> ids = new HashSet<>();
for (int index = 0; index < entries.size(); index++) {
Entry entry = entries.get(index);
int expectedNumber = LEGACY_LEVEL_COUNT + index + 1;
if (entry.number != expectedNumber) {
throw new IllegalArgumentException(source + " expected level number "
+ expectedNumber + " but found " + entry.number);
}
String expectedWorld = EXPECTED_WORLDS.get(index / LEVELS_PER_WORLD);
if (!expectedWorld.equals(entry.worldId)) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " expected world '" + expectedWorld + "' but found '" + entry.worldId + "'");
}
if (!ids.add(entry.levelId)) {
throw new IllegalArgumentException(source + " duplicates level id '"
+ entry.levelId + "'");
}
if (!entry.levelId.startsWith(entry.worldId + ".")) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " id must start with '" + entry.worldId + ".'");
}
int worldIndex = index % LEVELS_PER_WORLD;
Role expectedRole = worldIndex == 11 ? Role.CHALLENGE
: worldIndex == 12 ? Role.FINALE : Role.MAIN;
if (entry.role != expectedRole) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " expected role " + expectedRole + " but found " + entry.role);
}
boolean introduces = !"-".equals(entry.introducedMechanic);
if (entry.role != Role.MAIN && introduces) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " cannot introduce a mechanic in role " + entry.role);
}
if (introduces && !entry.mechanics.contains(entry.introducedMechanic)) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " introduced mechanic '" + entry.introducedMechanic
+ "' is absent from mechanics");
}
if (entry.targetDeaths < 0 || entry.targetSeconds <= 0) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " has invalid challenge targets");
}
if (!entry.collectible) {
throw new IllegalArgumentException(source + " level " + entry.number
+ " must define a collectible");
}
}
}
private static String required(String value, String source, int line, String field) {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException(source + ":" + line + " requires " + field);
}
return value;
}
private static int integer(String value, String source, int line, String field) {
try {
return Integer.parseInt(required(value, source, line, field));
} catch (NumberFormatException error) {
throw new IllegalArgumentException(source + ":" + line
+ " has invalid integer for " + field + ": '" + value + "'");
}
}
private static void requireEqual(String source, int number, String field,
String expected, String actual) {
if (!expected.equals(actual)) throw mismatch(source, number, field, expected, actual);
}
private static IllegalArgumentException mismatch(String source, int number, String field,
String expected, String actual) {
return new IllegalArgumentException(source + " level " + number + " " + field
+ " expected '" + expected + "' but found '" + actual + "'");
}
}
@@ -0,0 +1,53 @@
number worldId levelId role title introducedMechanic mechanics targetDeaths targetSeconds collectible
18 foundations foundations.threefold-proof MAIN Threefold Proof challenge-stars movement,jump,challenge-stars 0 30 true
19 foundations foundations.first-return MAIN First Return - movement,jump 0 28 true
20 foundations foundations.covered-path MAIN Covered Path corpse-bridge movement,jump,death,corpse-bridge,spikes 2 42 true
21 foundations foundations.held-open MAIN Held Open pressure movement,jump,death,pressure,door 1 45 true
22 foundations foundations.clockwork-step MAIN Clockwork Step toggle movement,jump,toggle,moving-platform 0 48 true
23 foundations foundations.remote-volunteer MAIN Remote Volunteer saw-launch movement,jump,death,saw-launch,pressure,door 1 50 true
24 foundations foundations.frozen-step MAIN Frozen Step frozen-body movement,jump,death,frozen-body 1 48 true
25 foundations foundations.living-wire MAIN Living Wire conductive-body movement,jump,death,conductive-body,door 1 52 true
26 foundations foundations.borrowed-time MAIN Borrowed Time timed-switch movement,jump,timed-switch,moving-platform,pit 0 55 true
27 foundations foundations.two-keys MAIN Two Keys signal-and movement,jump,death,pressure,conductive-body,signal-and,door 2 65 true
28 foundations foundations.clean-restart MAIN Clean Restart - movement,jump,death,saw-launch,frozen-body,conductive-body 3 75 true
29 foundations foundations.minimum-cost CHALLENGE Minimum Cost - movement,jump,death,corpse-bridge,pressure,timed-switch 2 65 true
30 foundations foundations.foundation-final FINALE Foundation Relay - movement,jump,death,corpse-bridge,pressure,toggle,timed-switch,moving-platform,door 3 90 true
31 physics physics.mass-basics MAIN Weight Class weighted-object movement,jump,weighted-object,pressure,door 0 45 true
32 physics physics.stack-order MAIN Stack Order object-stacking movement,jump,weighted-object,object-stacking 0 50 true
33 physics physics.launch-window MAIN Launch Window - movement,jump,saw-launch,moving-platform 1 55 true
34 physics physics.crusher-rhythm MAIN Press Cycle crusher movement,jump,death,crusher,pressure 1 55 true
35 physics physics.rolling-signal MAIN Rolling Signal moving-hazard movement,jump,weighted-object,moving-hazard,pressure 0 60 true
36 physics physics.returning-bolt MAIN Returning Bolt projectile movement,jump,death,projectile,pressure 1 60 true
37 physics physics.blast-delivery MAIN Blast Delivery explosion movement,jump,death,explosion,weighted-object 1 65 true
38 physics physics.moving-load MAIN Moving Load - movement,jump,weighted-object,moving-platform,pit 0 65 true
39 physics physics.counter-balance MAIN Counter Balance - movement,jump,weighted-object,object-stacking,signal-and 0 70 true
40 physics physics.pit-ferry MAIN Pit Ferry - movement,jump,death,corpse-bridge,moving-platform,pit 2 75 true
41 physics physics.mass-and-power MAIN Mass and Power - movement,jump,weighted-object,conductive-body,signal-and,door 1 80 true
42 physics physics.no-spare-mass CHALLENGE No Spare Mass - movement,jump,weighted-object,object-stacking,crusher 0 75 true
43 physics physics.physics-final FINALE Chain Reaction - movement,jump,death,weighted-object,projectile,explosion,crusher,moving-platform,door 3 100 true
44 states states.pinned-body MAIN Pinned Lesson pinned-body movement,jump,death,pinned-body,pressure 1 50 true
45 states states.crushed-path MAIN Low Profile crushed-body movement,jump,death,crushed-body,crusher 1 55 true
46 states states.burned-bridge MAIN Ashen Bridge burned-body movement,jump,death,burned-body,fire 2 60 true
47 states states.frozen-weight MAIN Cold Mass - movement,jump,death,frozen-body,weighted-object,pressure 1 60 true
48 states states.conductive-clock MAIN Live Timer - movement,jump,death,conductive-body,timed-switch,door 1 65 true
49 states states.transformed-bridge MAIN Changed Support - movement,jump,death,pinned-body,crushed-body,corpse-bridge 2 70 true
50 states states.frozen-conductor MAIN Cold Circuit - movement,jump,death,frozen-body,conductive-body,signal-and 2 75 true
51 states states.burned-switch MAIN False Weight - movement,jump,death,burned-body,pressure,door 2 75 true
52 states states.crushed-projectile MAIN Low Crossing - movement,jump,death,crushed-body,projectile 1 70 true
53 states states.timed-freeze MAIN Melting Window - movement,jump,death,frozen-body,timed-switch,moving-platform 1 75 true
54 states states.state-sequence MAIN State Sequence - movement,jump,death,pinned-body,crushed-body,burned-body,door 3 90 true
55 states states.state-limit CHALLENGE State Limit - movement,jump,death,frozen-body,conductive-body,burned-body 3 85 true
56 states states.states-final FINALE Matter Final - movement,jump,death,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,timed-switch,door 4 110 true
57 mastery mastery.relay-chain MAIN Relay Chain - movement,jump,death,conductive-body,signal-and,timed-switch,door 2 80 true
58 mastery mastery.moving-window MAIN Moving Window - movement,jump,death,moving-platform,timed-switch,crusher,pit 1 85 true
59 mastery mastery.body-procession MAIN Body Procession - movement,jump,death,frozen-body,conductive-body,corpse-bridge 3 90 true
60 mastery mastery.hazard-gauntlet MAIN Hazard Gauntlet - movement,jump,death,saw-launch,crusher,projectile,fire 3 95 true
61 mastery mastery.weighted-states MAIN Weighted States - movement,jump,death,weighted-object,frozen-body,burned-body,signal-and 2 95 true
62 mastery mastery.restart-proof MAIN Restart Proof - movement,jump,death,corpse-bridge,timed-switch,moving-platform,pit 2 90 true
63 mastery mastery.vertical-procession MAIN Vertical Procession - movement,jump,death,pinned-body,moving-platform,pressure,door 2 100 true
64 mastery mastery.blast-and-freeze MAIN Blast and Freeze - movement,jump,death,explosion,frozen-body,weighted-object 2 95 true
65 mastery mastery.four-signals MAIN Four Signals - movement,jump,death,pressure,toggle,timed-switch,conductive-body,signal-and,door 3 110 true
66 mastery mastery.recovery-master MAIN Recovery Master - movement,jump,death,timed-switch,moving-platform,pit,door 1 100 true
67 mastery mastery.final-rehearsal MAIN Final Rehearsal - movement,jump,death,saw-launch,frozen-body,conductive-body,weighted-object,moving-platform 4 120 true
68 mastery mastery.perfect-procession CHALLENGE Perfect Procession - movement,jump,death,frozen-body,conductive-body,weighted-object,timed-switch,signal-and 3 110 true
69 mastery mastery.mastery-final FINALE Last Reliquary - movement,jump,death,corpse-bridge,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,weighted-object,moving-platform,signal-and,door 5 150 true
1 number worldId levelId role title introducedMechanic mechanics targetDeaths targetSeconds collectible
2 18 foundations foundations.threefold-proof MAIN Threefold Proof challenge-stars movement,jump,challenge-stars 0 30 true
3 19 foundations foundations.first-return MAIN First Return - movement,jump 0 28 true
4 20 foundations foundations.covered-path MAIN Covered Path corpse-bridge movement,jump,death,corpse-bridge,spikes 2 42 true
5 21 foundations foundations.held-open MAIN Held Open pressure movement,jump,death,pressure,door 1 45 true
6 22 foundations foundations.clockwork-step MAIN Clockwork Step toggle movement,jump,toggle,moving-platform 0 48 true
7 23 foundations foundations.remote-volunteer MAIN Remote Volunteer saw-launch movement,jump,death,saw-launch,pressure,door 1 50 true
8 24 foundations foundations.frozen-step MAIN Frozen Step frozen-body movement,jump,death,frozen-body 1 48 true
9 25 foundations foundations.living-wire MAIN Living Wire conductive-body movement,jump,death,conductive-body,door 1 52 true
10 26 foundations foundations.borrowed-time MAIN Borrowed Time timed-switch movement,jump,timed-switch,moving-platform,pit 0 55 true
11 27 foundations foundations.two-keys MAIN Two Keys signal-and movement,jump,death,pressure,conductive-body,signal-and,door 2 65 true
12 28 foundations foundations.clean-restart MAIN Clean Restart - movement,jump,death,saw-launch,frozen-body,conductive-body 3 75 true
13 29 foundations foundations.minimum-cost CHALLENGE Minimum Cost - movement,jump,death,corpse-bridge,pressure,timed-switch 2 65 true
14 30 foundations foundations.foundation-final FINALE Foundation Relay - movement,jump,death,corpse-bridge,pressure,toggle,timed-switch,moving-platform,door 3 90 true
15 31 physics physics.mass-basics MAIN Weight Class weighted-object movement,jump,weighted-object,pressure,door 0 45 true
16 32 physics physics.stack-order MAIN Stack Order object-stacking movement,jump,weighted-object,object-stacking 0 50 true
17 33 physics physics.launch-window MAIN Launch Window - movement,jump,saw-launch,moving-platform 1 55 true
18 34 physics physics.crusher-rhythm MAIN Press Cycle crusher movement,jump,death,crusher,pressure 1 55 true
19 35 physics physics.rolling-signal MAIN Rolling Signal moving-hazard movement,jump,weighted-object,moving-hazard,pressure 0 60 true
20 36 physics physics.returning-bolt MAIN Returning Bolt projectile movement,jump,death,projectile,pressure 1 60 true
21 37 physics physics.blast-delivery MAIN Blast Delivery explosion movement,jump,death,explosion,weighted-object 1 65 true
22 38 physics physics.moving-load MAIN Moving Load - movement,jump,weighted-object,moving-platform,pit 0 65 true
23 39 physics physics.counter-balance MAIN Counter Balance - movement,jump,weighted-object,object-stacking,signal-and 0 70 true
24 40 physics physics.pit-ferry MAIN Pit Ferry - movement,jump,death,corpse-bridge,moving-platform,pit 2 75 true
25 41 physics physics.mass-and-power MAIN Mass and Power - movement,jump,weighted-object,conductive-body,signal-and,door 1 80 true
26 42 physics physics.no-spare-mass CHALLENGE No Spare Mass - movement,jump,weighted-object,object-stacking,crusher 0 75 true
27 43 physics physics.physics-final FINALE Chain Reaction - movement,jump,death,weighted-object,projectile,explosion,crusher,moving-platform,door 3 100 true
28 44 states states.pinned-body MAIN Pinned Lesson pinned-body movement,jump,death,pinned-body,pressure 1 50 true
29 45 states states.crushed-path MAIN Low Profile crushed-body movement,jump,death,crushed-body,crusher 1 55 true
30 46 states states.burned-bridge MAIN Ashen Bridge burned-body movement,jump,death,burned-body,fire 2 60 true
31 47 states states.frozen-weight MAIN Cold Mass - movement,jump,death,frozen-body,weighted-object,pressure 1 60 true
32 48 states states.conductive-clock MAIN Live Timer - movement,jump,death,conductive-body,timed-switch,door 1 65 true
33 49 states states.transformed-bridge MAIN Changed Support - movement,jump,death,pinned-body,crushed-body,corpse-bridge 2 70 true
34 50 states states.frozen-conductor MAIN Cold Circuit - movement,jump,death,frozen-body,conductive-body,signal-and 2 75 true
35 51 states states.burned-switch MAIN False Weight - movement,jump,death,burned-body,pressure,door 2 75 true
36 52 states states.crushed-projectile MAIN Low Crossing - movement,jump,death,crushed-body,projectile 1 70 true
37 53 states states.timed-freeze MAIN Melting Window - movement,jump,death,frozen-body,timed-switch,moving-platform 1 75 true
38 54 states states.state-sequence MAIN State Sequence - movement,jump,death,pinned-body,crushed-body,burned-body,door 3 90 true
39 55 states states.state-limit CHALLENGE State Limit - movement,jump,death,frozen-body,conductive-body,burned-body 3 85 true
40 56 states states.states-final FINALE Matter Final - movement,jump,death,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,timed-switch,door 4 110 true
41 57 mastery mastery.relay-chain MAIN Relay Chain - movement,jump,death,conductive-body,signal-and,timed-switch,door 2 80 true
42 58 mastery mastery.moving-window MAIN Moving Window - movement,jump,death,moving-platform,timed-switch,crusher,pit 1 85 true
43 59 mastery mastery.body-procession MAIN Body Procession - movement,jump,death,frozen-body,conductive-body,corpse-bridge 3 90 true
44 60 mastery mastery.hazard-gauntlet MAIN Hazard Gauntlet - movement,jump,death,saw-launch,crusher,projectile,fire 3 95 true
45 61 mastery mastery.weighted-states MAIN Weighted States - movement,jump,death,weighted-object,frozen-body,burned-body,signal-and 2 95 true
46 62 mastery mastery.restart-proof MAIN Restart Proof - movement,jump,death,corpse-bridge,timed-switch,moving-platform,pit 2 90 true
47 63 mastery mastery.vertical-procession MAIN Vertical Procession - movement,jump,death,pinned-body,moving-platform,pressure,door 2 100 true
48 64 mastery mastery.blast-and-freeze MAIN Blast and Freeze - movement,jump,death,explosion,frozen-body,weighted-object 2 95 true
49 65 mastery mastery.four-signals MAIN Four Signals - movement,jump,death,pressure,toggle,timed-switch,conductive-body,signal-and,door 3 110 true
50 66 mastery mastery.recovery-master MAIN Recovery Master - movement,jump,death,timed-switch,moving-platform,pit,door 1 100 true
51 67 mastery mastery.final-rehearsal MAIN Final Rehearsal - movement,jump,death,saw-launch,frozen-body,conductive-body,weighted-object,moving-platform 4 120 true
52 68 mastery mastery.perfect-procession CHALLENGE Perfect Procession - movement,jump,death,frozen-body,conductive-body,weighted-object,timed-switch,signal-and 3 110 true
53 69 mastery mastery.mastery-final FINALE Last Reliquary - movement,jump,death,corpse-bridge,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,weighted-object,moving-platform,signal-and,door 5 150 true
@@ -1 +1 @@
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json","level-17.json","level-18.json"]}
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json","level-17.json","level-18.json","level-19.json","level-20.json","level-21.json","level-22.json"]}
@@ -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":[]
}
@@ -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=5; 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();
}
}