From 4869649b474cb8126faad5ba34000e30b3dac93e Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Wed, 19 Aug 2026 09:39:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D1=84=D0=B8=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BC=D0=B0=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=86=D1=83=20=D1=87=D0=B5=D1=82=D1=8B=D1=80=D1=91=D1=85?= =?UTF-8?q?=20=D0=BC=D0=B8=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/issues.jsonl | 2 +- README.md | 5 +- app/build.gradle | 1 + docs/CURRENT_ROADMAP.md | 5 + docs/STAGE6_CONTENT_MATRIX.md | 92 +++++++ level-format/build.gradle | 16 ++ .../game/LevelContentTool.java | 16 +- .../game/ProductionContentMatrix.java | 256 ++++++++++++++++++ .../content/production-content-matrix.tsv | 53 ++++ .../game/LevelContentToolTest.java | 44 +++ .../game/ProductionContentMatrixTest.java | 95 +++++++ 11 files changed, 582 insertions(+), 3 deletions(-) create mode 100644 docs/STAGE6_CONTENT_MATRIX.md create mode 100644 level-format/src/main/java/com/projectsacrifice/game/ProductionContentMatrix.java create mode 100644 level-format/src/main/resources/content/production-content-matrix.tsv create mode 100644 level-format/src/test/java/com/projectsacrifice/game/ProductionContentMatrixTest.java diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0d61d04..01c588e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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: claimed after challenge/progression commit 3de8d45 passed 116 app JVM + 2 content-tool JVM and cold host-GPU exact Android 52/52. Executable production matrix now fixes release at 69 levels: workshop 1-17 plus Foundations 18-30, Physics 31-43, States 44-56, Mastery 57-69; each world has 11 MAIN + 1 CHALLENGE + 1 FINALE. TDD REDs captured missing matrix contract and missing validate-matrix CLI; GREEN has 7/7 level-format + 116/116 app JVM, both validators, lintDebug, app/test APK and ZIP integrity. Android preBuild fail-closes matrix rules and authored-prefix drift; authored production prefix is Level 18 only. Next wave is Foundations 19-30 through graph/JSON/bot/DoD; issue remains in_progress.","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-19T06:39:18Z","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} diff --git a/README.md b/README.md index 93d0f3c..d0d279b 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,10 @@ Scope and final evidence are in `docs/STAGE6_CHARACTER_STATES.md`, 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 +`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`. 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`. diff --git a/app/build.gradle b/app/build.gradle index 24a9488..ad9b8e3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,4 +47,5 @@ dependencies { tasks.named('preBuild').configure { dependsOn ':level-format:validateLevels' + dependsOn ':level-format:validateContentMatrix' } diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index 6850abe..7706858 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -208,6 +208,11 @@ 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. ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней diff --git a/docs/STAGE6_CONTENT_MATRIX.md b/docs/STAGE6_CONTENT_MATRIX.md new file mode 100644 index 0000000..a606e28 --- /dev/null +++ b/docs/STAGE6_CONTENT_MATRIX.md @@ -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=1; 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 на 19 августа 2026 года: + +- `ProductionContentMatrixTest` + `LevelContentToolTest`: `7/7`; +- app JVM regression: `116/116`; +- `validateLevels`: `18` authored levels; +- `validateContentMatrix`: `4` worlds, `52` planned, `1` authored production row, + release total `69`; +- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN; +- APK ZIP integrity: no errors; matrix и `level-18.json` присутствуют в package; +- debug APK SHA-256: + `73d9647f7d5456c79e6fcfa828e0524ccdd5cdf4777a3998c51016c38ce2652c`. + +Этот срез не меняет runtime, physics или geometry, поэтому device playthrough +матрица остаётся acceptance gate следующего authoring wave, а не подменяется +build-time validator тестами. diff --git a/level-format/build.gradle b/level-format/build.gradle index 15aeed8..a7f793f 100644 --- a/level-format/build.gradle +++ b/level-format/build.gradle @@ -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.' diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java b/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java index cd08bec..88aa4d8 100644 --- a/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelContentTool.java @@ -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 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 | preview "); + errors.println("Usage: validate | validate-matrix " + + " | preview "); return 2; } catch (IllegalArgumentException | IOException error) { errors.println(error.getMessage()); diff --git a/level-format/src/main/java/com/projectsacrifice/game/ProductionContentMatrix.java b/level-format/src/main/java/com/projectsacrifice/game/ProductionContentMatrix.java new file mode 100644 index 0000000..ad09442 --- /dev/null +++ b/level-format/src/main/java/com/projectsacrifice/game/ProductionContentMatrix.java @@ -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 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 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 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 entries; + + private ProductionContentMatrix(List 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 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 entries() { + return entries; + } + + public List worldIds() { + return EXPECTED_WORLDS; + } + + public List entriesForWorld(String worldId) { + List 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 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 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 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 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 + "'"); + } +} diff --git a/level-format/src/main/resources/content/production-content-matrix.tsv b/level-format/src/main/resources/content/production-content-matrix.tsv new file mode 100644 index 0000000..44be74a --- /dev/null +++ b/level-format/src/main/resources/content/production-content-matrix.tsv @@ -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 diff --git a/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java b/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java index d52ab17..7292406 100644 --- a/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java +++ b/level-format/src/test/java/com/projectsacrifice/game/LevelContentToolTest.java @@ -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=1; 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\"]}"); diff --git a/level-format/src/test/java/com/projectsacrifice/game/ProductionContentMatrixTest.java b/level-format/src/test/java/com/projectsacrifice/game/ProductionContentMatrixTest.java new file mode 100644 index 0000000..de4c4cd --- /dev/null +++ b/level-format/src/test/java/com/projectsacrifice/game/ProductionContentMatrixTest.java @@ -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 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(); + } +}