From 098ce5caa44be64dcc09054340de68acce055e33 Mon Sep 17 00:00:00 2001 From: Christian Schindler Date: Sat, 15 Aug 2026 08:50:44 +0200 Subject: [PATCH] feat(content): add twelve-form Pokemon fan catalog --- content-pokemon/README.md | 7 + content-pokemon/build.gradle.kts | 31 + .../projectcapture/content/v1/assets.json | 78 +++ .../projectcapture/content/v1/manifest.json | 31 + .../projectcapture/content/v1/moves.json | 23 + .../projectcapture/content/v1/recipes.json | 42 ++ .../content/v1/spawn-rules.json | 18 + .../projectcapture/content/v1/species.json | 85 +++ content-tools/README.md | 10 + content-tools/build.gradle.kts | 46 ++ .../contenttools/ContentBundle.java | 112 ++++ .../contenttools/ContentTool.java | 67 ++ .../contenttools/ContentValidator.java | 626 ++++++++++++++++++ .../contenttools/ValidationIssue.java | 8 + .../contenttools/ValidationReport.java | 29 + .../contenttools/ContentToolTest.java | 43 ++ .../contenttools/ContentValidatorTest.java | 171 +++++ docs/IMPLEMENTATION_PLAN.md | 2 +- 18 files changed, 1428 insertions(+), 1 deletion(-) create mode 100644 content-pokemon/README.md create mode 100644 content-pokemon/build.gradle.kts create mode 100644 content-pokemon/src/main/resources/projectcapture/content/v1/assets.json create mode 100644 content-pokemon/src/main/resources/projectcapture/content/v1/manifest.json create mode 100644 content-pokemon/src/main/resources/projectcapture/content/v1/moves.json create mode 100644 content-pokemon/src/main/resources/projectcapture/content/v1/recipes.json create mode 100644 content-pokemon/src/main/resources/projectcapture/content/v1/spawn-rules.json create mode 100644 content-pokemon/src/main/resources/projectcapture/content/v1/species.json create mode 100644 content-tools/README.md create mode 100644 content-tools/build.gradle.kts create mode 100644 content-tools/src/main/java/dev/projectcapture/contenttools/ContentBundle.java create mode 100644 content-tools/src/main/java/dev/projectcapture/contenttools/ContentTool.java create mode 100644 content-tools/src/main/java/dev/projectcapture/contenttools/ContentValidator.java create mode 100644 content-tools/src/main/java/dev/projectcapture/contenttools/ValidationIssue.java create mode 100644 content-tools/src/main/java/dev/projectcapture/contenttools/ValidationReport.java create mode 100644 content-tools/src/test/java/dev/projectcapture/contenttools/ContentToolTest.java create mode 100644 content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java diff --git a/content-pokemon/README.md b/content-pokemon/README.md new file mode 100644 index 0000000..a2a11b3 --- /dev/null +++ b/content-pokemon/README.md @@ -0,0 +1,7 @@ +# Unofficial fan-content catalog + +`content-pokemon` contains the openly inspectable, non-commercial and unofficial twelve-form fanmod catalog. Every document and content entry is explicitly marked with `unofficialFanContent: true`. + +The catalog intentionally contains no copied game files and no generated binary assets. Asset records with `status: "PLANNED"` are deterministic production specifications and safe relative target paths, not claims that an asset already exists. + +Validation is performed by `:content-tools:validateFanContent`. The catalog also records `nonCommercial: true` and `rightsReviewRequired: true`; these declarations must be preserved by any distribution workflow. This module does not configure a publisher, remote or upload target. diff --git a/content-pokemon/build.gradle.kts b/content-pokemon/build.gradle.kts new file mode 100644 index 0000000..02bb91c --- /dev/null +++ b/content-pokemon/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + `java-library` +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +tasks.register("verifyNoBinaryAssets") { + group = "verification" + description = "Rejects copied or generated binary assets from the metadata-only fan-content catalog." + inputs.files(fileTree("src/main/resources") { + include( + "**/*.jar", "**/*.zip", "**/*.class", "**/*.png", "**/*.jpg", "**/*.jpeg", + "**/*.ogg", "**/*.wav", "**/*.mp3", "**/*.bbmodel", "**/*.blockymodel", "**/*.blockyanim" + ) + }) + + doLast { + check(inputs.files.isEmpty) { + "content-pokemon may contain metadata only; binary/generated files found: " + + inputs.files.files.joinToString { it.path } + } + } +} + +tasks.named("check") { + dependsOn("verifyNoBinaryAssets") +} diff --git a/content-pokemon/src/main/resources/projectcapture/content/v1/assets.json b/content-pokemon/src/main/resources/projectcapture/content/v1/assets.json new file mode 100644 index 0000000..f147ff8 --- /dev/null +++ b/content-pokemon/src/main/resources/projectcapture/content/v1/assets.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": 1, + "unofficialFanContent": true, + "items": [ + { + "id": "projectcapture:bulbasaur-assets", "speciesId": "projectcapture:bulbasaur", "status": "PLANNED", "seed": 1001, + "bodyPlan": "QUADRUPED_SMALL", "palette": ["#68B878", "#78C8B0", "#405060", "#D85868"], "sockets": ["head", "back", "tail"], + "source": "assets/fan/bulbasaur/source/bulbasaur.bbmodel", "model": "assets/fan/bulbasaur/models/bulbasaur.blockymodel", "texture": "assets/fan/bulbasaur/textures/bulbasaur.png", "recipe": "assets/fan/bulbasaur/recipes/bulbasaur.asset.json", + "animations": { "idle": "assets/fan/bulbasaur/animations/bulbasaur_idle.blockyanim", "walk": "assets/fan/bulbasaur/animations/bulbasaur_walk.blockyanim", "attack": "assets/fan/bulbasaur/animations/bulbasaur_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:ivysaur-assets", "speciesId": "projectcapture:ivysaur", "status": "PLANNED", "seed": 1002, + "bodyPlan": "QUADRUPED_MEDIUM", "palette": ["#58A890", "#78C8B0", "#405060", "#D85878"], "sockets": ["head", "back", "tail"], + "source": "assets/fan/ivysaur/source/ivysaur.bbmodel", "model": "assets/fan/ivysaur/models/ivysaur.blockymodel", "texture": "assets/fan/ivysaur/textures/ivysaur.png", "recipe": "assets/fan/ivysaur/recipes/ivysaur.asset.json", + "animations": { "idle": "assets/fan/ivysaur/animations/ivysaur_idle.blockyanim", "walk": "assets/fan/ivysaur/animations/ivysaur_walk.blockyanim", "attack": "assets/fan/ivysaur/animations/ivysaur_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:venusaur-assets", "speciesId": "projectcapture:venusaur", "status": "PLANNED", "seed": 1003, + "bodyPlan": "QUADRUPED_LARGE", "palette": ["#489878", "#68B8A0", "#384858", "#D85868"], "sockets": ["head", "back", "tail"], + "source": "assets/fan/venusaur/source/venusaur.bbmodel", "model": "assets/fan/venusaur/models/venusaur.blockymodel", "texture": "assets/fan/venusaur/textures/venusaur.png", "recipe": "assets/fan/venusaur/recipes/venusaur.asset.json", + "animations": { "idle": "assets/fan/venusaur/animations/venusaur_idle.blockyanim", "walk": "assets/fan/venusaur/animations/venusaur_walk.blockyanim", "attack": "assets/fan/venusaur/animations/venusaur_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:charmander-assets", "speciesId": "projectcapture:charmander", "status": "PLANNED", "seed": 1004, + "bodyPlan": "BIPED_SMALL_TAILED", "palette": ["#E89048", "#F0C070", "#405060", "#F05838"], "sockets": ["head", "back", "tail", "hand_left", "hand_right"], + "source": "assets/fan/charmander/source/charmander.bbmodel", "model": "assets/fan/charmander/models/charmander.blockymodel", "texture": "assets/fan/charmander/textures/charmander.png", "recipe": "assets/fan/charmander/recipes/charmander.asset.json", + "animations": { "idle": "assets/fan/charmander/animations/charmander_idle.blockyanim", "walk": "assets/fan/charmander/animations/charmander_walk.blockyanim", "attack": "assets/fan/charmander/animations/charmander_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:charmeleon-assets", "speciesId": "projectcapture:charmeleon", "status": "PLANNED", "seed": 1005, + "bodyPlan": "BIPED_MEDIUM_TAILED", "palette": ["#D86848", "#F0B860", "#384858", "#F05030"], "sockets": ["head", "back", "tail", "hand_left", "hand_right"], + "source": "assets/fan/charmeleon/source/charmeleon.bbmodel", "model": "assets/fan/charmeleon/models/charmeleon.blockymodel", "texture": "assets/fan/charmeleon/textures/charmeleon.png", "recipe": "assets/fan/charmeleon/recipes/charmeleon.asset.json", + "animations": { "idle": "assets/fan/charmeleon/animations/charmeleon_idle.blockyanim", "walk": "assets/fan/charmeleon/animations/charmeleon_walk.blockyanim", "attack": "assets/fan/charmeleon/animations/charmeleon_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:charizard-assets", "speciesId": "projectcapture:charizard", "status": "PLANNED", "seed": 1006, + "bodyPlan": "BIPED_LARGE_WINGED", "palette": ["#D87048", "#F0B858", "#385070", "#F05028"], "sockets": ["head", "back", "tail", "wing_left", "wing_right", "hand_left", "hand_right"], + "source": "assets/fan/charizard/source/charizard.bbmodel", "model": "assets/fan/charizard/models/charizard.blockymodel", "texture": "assets/fan/charizard/textures/charizard.png", "recipe": "assets/fan/charizard/recipes/charizard.asset.json", + "animations": { "idle": "assets/fan/charizard/animations/charizard_idle.blockyanim", "walk": "assets/fan/charizard/animations/charizard_walk.blockyanim", "attack": "assets/fan/charizard/animations/charizard_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:squirtle-assets", "speciesId": "projectcapture:squirtle", "status": "PLANNED", "seed": 1007, + "bodyPlan": "BIPED_SMALL_SHELLED", "palette": ["#58A8C8", "#E8D098", "#987048", "#405060"], "sockets": ["head", "back", "tail", "hand_left", "hand_right"], + "source": "assets/fan/squirtle/source/squirtle.bbmodel", "model": "assets/fan/squirtle/models/squirtle.blockymodel", "texture": "assets/fan/squirtle/textures/squirtle.png", "recipe": "assets/fan/squirtle/recipes/squirtle.asset.json", + "animations": { "idle": "assets/fan/squirtle/animations/squirtle_idle.blockyanim", "walk": "assets/fan/squirtle/animations/squirtle_walk.blockyanim", "attack": "assets/fan/squirtle/animations/squirtle_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:wartortle-assets", "speciesId": "projectcapture:wartortle", "status": "PLANNED", "seed": 1008, + "bodyPlan": "BIPED_MEDIUM_SHELLED", "palette": ["#5088C0", "#E8D8B0", "#887058", "#405078"], "sockets": ["head", "back", "tail", "hand_left", "hand_right"], + "source": "assets/fan/wartortle/source/wartortle.bbmodel", "model": "assets/fan/wartortle/models/wartortle.blockymodel", "texture": "assets/fan/wartortle/textures/wartortle.png", "recipe": "assets/fan/wartortle/recipes/wartortle.asset.json", + "animations": { "idle": "assets/fan/wartortle/animations/wartortle_idle.blockyanim", "walk": "assets/fan/wartortle/animations/wartortle_walk.blockyanim", "attack": "assets/fan/wartortle/animations/wartortle_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:blastoise-assets", "speciesId": "projectcapture:blastoise", "status": "PLANNED", "seed": 1009, + "bodyPlan": "BIPED_LARGE_SHELLED", "palette": ["#3878B0", "#E8D8B0", "#786048", "#384868"], "sockets": ["head", "back", "tail", "cannon_left", "cannon_right", "hand_left", "hand_right"], + "source": "assets/fan/blastoise/source/blastoise.bbmodel", "model": "assets/fan/blastoise/models/blastoise.blockymodel", "texture": "assets/fan/blastoise/textures/blastoise.png", "recipe": "assets/fan/blastoise/recipes/blastoise.asset.json", + "animations": { "idle": "assets/fan/blastoise/animations/blastoise_idle.blockyanim", "walk": "assets/fan/blastoise/animations/blastoise_walk.blockyanim", "attack": "assets/fan/blastoise/animations/blastoise_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:pikachu-assets", "speciesId": "projectcapture:pikachu", "status": "PLANNED", "seed": 1025, + "bodyPlan": "BIPED_SMALL_TAILED", "palette": ["#F0D848", "#A06838", "#E85048", "#384050"], "sockets": ["head", "back", "tail", "hand_left", "hand_right"], + "source": "assets/fan/pikachu/source/pikachu.bbmodel", "model": "assets/fan/pikachu/models/pikachu.blockymodel", "texture": "assets/fan/pikachu/textures/pikachu.png", "recipe": "assets/fan/pikachu/recipes/pikachu.asset.json", + "animations": { "idle": "assets/fan/pikachu/animations/pikachu_idle.blockyanim", "walk": "assets/fan/pikachu/animations/pikachu_walk.blockyanim", "attack": "assets/fan/pikachu/animations/pikachu_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:raichu-assets", "speciesId": "projectcapture:raichu", "status": "PLANNED", "seed": 1026, + "bodyPlan": "BIPED_MEDIUM_TAILED", "palette": ["#D89040", "#F0D078", "#886040", "#384050"], "sockets": ["head", "back", "tail", "hand_left", "hand_right"], + "source": "assets/fan/raichu/source/raichu.bbmodel", "model": "assets/fan/raichu/models/raichu.blockymodel", "texture": "assets/fan/raichu/textures/raichu.png", "recipe": "assets/fan/raichu/recipes/raichu.asset.json", + "animations": { "idle": "assets/fan/raichu/animations/raichu_idle.blockyanim", "walk": "assets/fan/raichu/animations/raichu_walk.blockyanim", "attack": "assets/fan/raichu/animations/raichu_attack.blockyanim" }, "unofficialFanContent": true + }, + { + "id": "projectcapture:ditto-assets", "speciesId": "projectcapture:ditto", "status": "PLANNED", "seed": 1132, + "bodyPlan": "AMORPHOUS_SMALL", "palette": ["#A878C8", "#D0A8E0", "#405060", "#F0E8F0"], "sockets": ["core", "surface"], + "source": "assets/fan/ditto/source/ditto.bbmodel", "model": "assets/fan/ditto/models/ditto.blockymodel", "texture": "assets/fan/ditto/textures/ditto.png", "recipe": "assets/fan/ditto/recipes/ditto.asset.json", + "animations": { "idle": "assets/fan/ditto/animations/ditto_idle.blockyanim", "walk": "assets/fan/ditto/animations/ditto_walk.blockyanim", "attack": "assets/fan/ditto/animations/ditto_attack.blockyanim" }, "unofficialFanContent": true + } + ] +} diff --git a/content-pokemon/src/main/resources/projectcapture/content/v1/manifest.json b/content-pokemon/src/main/resources/projectcapture/content/v1/manifest.json new file mode 100644 index 0000000..a073a4a --- /dev/null +++ b/content-pokemon/src/main/resources/projectcapture/content/v1/manifest.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "contentSetId": "projectcapture:twelve-form-fan-slice", + "contentProfile": "UNOFFICIAL_TWELVE_FORM_SLICE", + "unofficialFanContent": true, + "publicationStatus": "UNOFFICIAL_FAN_CONTENT", + "nonCommercial": true, + "rightsReviewRequired": true, + "gameplay": { + "minimumLevel": 1, + "maximumLevel": 100, + "maximumPartySize": 6, + "visibleCompanionLimit": 1, + "commandedMoveSlots": 3, + "starterGrantEnabled": false + }, + "expectedSpeciesIds": [ + "projectcapture:bulbasaur", + "projectcapture:ivysaur", + "projectcapture:venusaur", + "projectcapture:charmander", + "projectcapture:charmeleon", + "projectcapture:charizard", + "projectcapture:squirtle", + "projectcapture:wartortle", + "projectcapture:blastoise", + "projectcapture:pikachu", + "projectcapture:raichu", + "projectcapture:ditto" + ] +} diff --git a/content-pokemon/src/main/resources/projectcapture/content/v1/moves.json b/content-pokemon/src/main/resources/projectcapture/content/v1/moves.json new file mode 100644 index 0000000..ceac205 --- /dev/null +++ b/content-pokemon/src/main/resources/projectcapture/content/v1/moves.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "unofficialFanContent": true, + "items": [ + { "id": "projectcapture:tackle", "name": "Tackle", "element": "NORMAL", "role": "BASIC", "power": 28, "cooldownSeconds": 1.4, "unofficialFanContent": true }, + { "id": "projectcapture:guard-stance", "name": "Guard Stance", "element": "NORMAL", "role": "COMMAND", "power": 0, "cooldownSeconds": 9.0, "unofficialFanContent": true }, + { "id": "projectcapture:leafy-burst", "name": "Leafy Burst", "element": "GRASS", "role": "COMMAND", "power": 42, "cooldownSeconds": 5.0, "unofficialFanContent": true }, + { "id": "projectcapture:spore-veil", "name": "Spore Veil", "element": "GRASS", "role": "COMMAND", "power": 18, "cooldownSeconds": 8.0, "unofficialFanContent": true }, + { "id": "projectcapture:seed-pulse", "name": "Seed Pulse", "element": "GRASS", "role": "COMMAND", "power": 58, "cooldownSeconds": 7.5, "unofficialFanContent": true }, + { "id": "projectcapture:ember-dart", "name": "Ember Dart", "element": "FIRE", "role": "BASIC", "power": 31, "cooldownSeconds": 1.6, "unofficialFanContent": true }, + { "id": "projectcapture:heat-wave", "name": "Heat Wave", "element": "FIRE", "role": "COMMAND", "power": 52, "cooldownSeconds": 6.5, "unofficialFanContent": true }, + { "id": "projectcapture:flame-guard", "name": "Flame Guard", "element": "FIRE", "role": "COMMAND", "power": 0, "cooldownSeconds": 9.5, "unofficialFanContent": true }, + { "id": "projectcapture:sky-dive", "name": "Sky Dive", "element": "AIR", "role": "COMMAND", "power": 64, "cooldownSeconds": 8.5, "unofficialFanContent": true }, + { "id": "projectcapture:water-jet", "name": "Water Jet", "element": "WATER", "role": "BASIC", "power": 30, "cooldownSeconds": 1.5, "unofficialFanContent": true }, + { "id": "projectcapture:shell-guard", "name": "Shell Guard", "element": "WATER", "role": "COMMAND", "power": 0, "cooldownSeconds": 8.0, "unofficialFanContent": true }, + { "id": "projectcapture:mist-surge", "name": "Mist Surge", "element": "WATER", "role": "COMMAND", "power": 49, "cooldownSeconds": 6.0, "unofficialFanContent": true }, + { "id": "projectcapture:static-spark", "name": "Static Spark", "element": "ELECTRIC", "role": "BASIC", "power": 33, "cooldownSeconds": 1.5, "unofficialFanContent": true }, + { "id": "projectcapture:quick-step", "name": "Quick Step", "element": "NORMAL", "role": "COMMAND", "power": 24, "cooldownSeconds": 3.5, "unofficialFanContent": true }, + { "id": "projectcapture:thunder-arc", "name": "Thunder Arc", "element": "ELECTRIC", "role": "COMMAND", "power": 61, "cooldownSeconds": 7.0, "unofficialFanContent": true }, + { "id": "projectcapture:morph-strike", "name": "Morph Strike", "element": "NORMAL", "role": "BASIC", "power": 26, "cooldownSeconds": 1.5, "unofficialFanContent": true }, + { "id": "projectcapture:mirror-form", "name": "Mirror Form", "element": "NORMAL", "role": "COMMAND", "power": 0, "cooldownSeconds": 12.0, "unofficialFanContent": true } + ] +} diff --git a/content-pokemon/src/main/resources/projectcapture/content/v1/recipes.json b/content-pokemon/src/main/resources/projectcapture/content/v1/recipes.json new file mode 100644 index 0000000..51f78d2 --- /dev/null +++ b/content-pokemon/src/main/resources/projectcapture/content/v1/recipes.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "unofficialFanContent": true, + "items": [ + { + "id": "projectcapture:basic-capture-capsule", "category": "CAPTURE_DEVICE", "unlockedByDefault": true, + "output": { "itemId": "projectcapture:capture-capsule", "quantity": 4 }, + "ingredients": [ + { "itemId": "hytale:copper_ingot", "quantity": 1 }, + { "itemId": "hytale:plant_fiber", "quantity": 2 } + ], + "unofficialFanContent": true + }, + { + "id": "projectcapture:breeding-habitat", "category": "HABITAT", "unlockedByDefault": false, + "output": { "itemId": "projectcapture:breeding-habitat", "quantity": 1 }, + "ingredients": [ + { "itemId": "hytale:wood_plank", "quantity": 12 }, + { "itemId": "hytale:iron_ingot", "quantity": 4 } + ], + "unofficialFanContent": true + }, + { + "id": "projectcapture:gene-analyzer", "category": "ANALYSIS_TOOL", "unlockedByDefault": false, + "output": { "itemId": "projectcapture:gene-analyzer", "quantity": 1 }, + "ingredients": [ + { "itemId": "hytale:iron_ingot", "quantity": 6 }, + { "itemId": "hytale:crystal_shard", "quantity": 2 } + ], + "unofficialFanContent": true + }, + { + "id": "projectcapture:thunder-core", "category": "EVOLUTION_CATALYST", "unlockedByDefault": false, + "output": { "itemId": "projectcapture:thunder-core", "quantity": 1 }, + "ingredients": [ + { "itemId": "hytale:crystal_shard", "quantity": 4 }, + { "itemId": "hytale:copper_ingot", "quantity": 3 } + ], + "unofficialFanContent": true + } + ] +} diff --git a/content-pokemon/src/main/resources/projectcapture/content/v1/spawn-rules.json b/content-pokemon/src/main/resources/projectcapture/content/v1/spawn-rules.json new file mode 100644 index 0000000..10500bb --- /dev/null +++ b/content-pokemon/src/main/resources/projectcapture/content/v1/spawn-rules.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "unofficialFanContent": true, + "items": [ + { "id": "projectcapture:spawn-bulbasaur", "speciesId": "projectcapture:bulbasaur", "biomes": ["FOREST", "MEADOW"], "time": "DAY", "rarityWeight": 36, "minimumLevel": 3, "maximumLevel": 15, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-ivysaur", "speciesId": "projectcapture:ivysaur", "biomes": ["FOREST", "JUNGLE"], "time": "ANY", "rarityWeight": 12, "minimumLevel": 16, "maximumLevel": 34, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-venusaur", "speciesId": "projectcapture:venusaur", "biomes": ["JUNGLE"], "time": "DAY", "rarityWeight": 2, "minimumLevel": 34, "maximumLevel": 60, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-charmander", "speciesId": "projectcapture:charmander", "biomes": ["VOLCANIC", "BADLANDS"], "time": "DAY", "rarityWeight": 34, "minimumLevel": 3, "maximumLevel": 15, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-charmeleon", "speciesId": "projectcapture:charmeleon", "biomes": ["VOLCANIC", "BADLANDS"], "time": "ANY", "rarityWeight": 11, "minimumLevel": 16, "maximumLevel": 38, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-charizard", "speciesId": "projectcapture:charizard", "biomes": ["VOLCANIC", "MOUNTAINS"], "time": "DAY", "rarityWeight": 2, "minimumLevel": 38, "maximumLevel": 64, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-squirtle", "speciesId": "projectcapture:squirtle", "biomes": ["COAST", "RIVER"], "time": "DAY", "rarityWeight": 38, "minimumLevel": 3, "maximumLevel": 15, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-wartortle", "speciesId": "projectcapture:wartortle", "biomes": ["COAST", "SWAMP"], "time": "ANY", "rarityWeight": 12, "minimumLevel": 16, "maximumLevel": 38, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-blastoise", "speciesId": "projectcapture:blastoise", "biomes": ["COAST", "DEEP_WATER"], "time": "ANY", "rarityWeight": 2, "minimumLevel": 38, "maximumLevel": 64, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-pikachu", "speciesId": "projectcapture:pikachu", "biomes": ["FOREST", "MEADOW"], "time": "ANY", "rarityWeight": 24, "minimumLevel": 5, "maximumLevel": 30, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-raichu", "speciesId": "projectcapture:raichu", "biomes": ["HIGHLANDS", "STORM_FIELDS"], "time": "ANY", "rarityWeight": 4, "minimumLevel": 28, "maximumLevel": 58, "unofficialFanContent": true }, + { "id": "projectcapture:spawn-ditto", "speciesId": "projectcapture:ditto", "biomes": ["MEADOW", "CAVES", "SWAMP"], "time": "NIGHT", "rarityWeight": 6, "minimumLevel": 10, "maximumLevel": 50, "unofficialFanContent": true } + ] +} diff --git a/content-pokemon/src/main/resources/projectcapture/content/v1/species.json b/content-pokemon/src/main/resources/projectcapture/content/v1/species.json new file mode 100644 index 0000000..baa6f70 --- /dev/null +++ b/content-pokemon/src/main/resources/projectcapture/content/v1/species.json @@ -0,0 +1,85 @@ +{ + "schemaVersion": 1, + "unofficialFanContent": true, + "items": [ + { + "id": "projectcapture:bulbasaur", "displayName": "Bulbasaur", "catalogNumber": 1, "form": "BASE", "elements": ["GRASS", "TOXIN"], + "captureDifficulty": 0.22, "baseStats": { "vitality": 45, "power": 42, "guard": 44, "agility": 40 }, + "basicMoveId": "projectcapture:tackle", "commandMoveIds": ["projectcapture:leafy-burst", "projectcapture:spore-veil", "projectcapture:guard-stance"], + "evolution": { "method": "LEVEL", "targetSpeciesId": "projectcapture:ivysaur", "minimumLevel": 16 }, + "assetId": "projectcapture:bulbasaur-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:ivysaur", "displayName": "Ivysaur", "catalogNumber": 2, "form": "STAGE_1", "elements": ["GRASS", "TOXIN"], + "captureDifficulty": 0.42, "baseStats": { "vitality": 60, "power": 58, "guard": 60, "agility": 54 }, + "basicMoveId": "projectcapture:tackle", "commandMoveIds": ["projectcapture:leafy-burst", "projectcapture:seed-pulse", "projectcapture:spore-veil"], + "evolution": { "method": "LEVEL", "targetSpeciesId": "projectcapture:venusaur", "minimumLevel": 32 }, + "assetId": "projectcapture:ivysaur-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:venusaur", "displayName": "Venusaur", "catalogNumber": 3, "form": "STAGE_2", "elements": ["GRASS", "TOXIN"], + "captureDifficulty": 0.72, "baseStats": { "vitality": 82, "power": 80, "guard": 84, "agility": 68 }, + "basicMoveId": "projectcapture:tackle", "commandMoveIds": ["projectcapture:seed-pulse", "projectcapture:spore-veil", "projectcapture:guard-stance"], + "assetId": "projectcapture:venusaur-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:charmander", "displayName": "Charmander", "catalogNumber": 4, "form": "BASE", "elements": ["FIRE"], + "captureDifficulty": 0.24, "baseStats": { "vitality": 39, "power": 50, "guard": 38, "agility": 56 }, + "basicMoveId": "projectcapture:ember-dart", "commandMoveIds": ["projectcapture:heat-wave", "projectcapture:quick-step", "projectcapture:flame-guard"], + "evolution": { "method": "LEVEL", "targetSpeciesId": "projectcapture:charmeleon", "minimumLevel": 16 }, + "assetId": "projectcapture:charmander-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:charmeleon", "displayName": "Charmeleon", "catalogNumber": 5, "form": "STAGE_1", "elements": ["FIRE"], + "captureDifficulty": 0.45, "baseStats": { "vitality": 58, "power": 66, "guard": 54, "agility": 70 }, + "basicMoveId": "projectcapture:ember-dart", "commandMoveIds": ["projectcapture:heat-wave", "projectcapture:quick-step", "projectcapture:flame-guard"], + "evolution": { "method": "LEVEL", "targetSpeciesId": "projectcapture:charizard", "minimumLevel": 36 }, + "assetId": "projectcapture:charmeleon-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:charizard", "displayName": "Charizard", "catalogNumber": 6, "form": "STAGE_2", "elements": ["FIRE", "AIR"], + "captureDifficulty": 0.76, "baseStats": { "vitality": 78, "power": 88, "guard": 72, "agility": 96 }, + "basicMoveId": "projectcapture:ember-dart", "commandMoveIds": ["projectcapture:heat-wave", "projectcapture:sky-dive", "projectcapture:flame-guard"], + "assetId": "projectcapture:charizard-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:squirtle", "displayName": "Squirtle", "catalogNumber": 7, "form": "BASE", "elements": ["WATER"], + "captureDifficulty": 0.23, "baseStats": { "vitality": 44, "power": 40, "guard": 64, "agility": 38 }, + "basicMoveId": "projectcapture:water-jet", "commandMoveIds": ["projectcapture:shell-guard", "projectcapture:mist-surge", "projectcapture:quick-step"], + "evolution": { "method": "LEVEL", "targetSpeciesId": "projectcapture:wartortle", "minimumLevel": 16 }, + "assetId": "projectcapture:squirtle-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:wartortle", "displayName": "Wartortle", "catalogNumber": 8, "form": "STAGE_1", "elements": ["WATER"], + "captureDifficulty": 0.43, "baseStats": { "vitality": 59, "power": 54, "guard": 78, "agility": 52 }, + "basicMoveId": "projectcapture:water-jet", "commandMoveIds": ["projectcapture:shell-guard", "projectcapture:mist-surge", "projectcapture:guard-stance"], + "evolution": { "method": "LEVEL", "targetSpeciesId": "projectcapture:blastoise", "minimumLevel": 36 }, + "assetId": "projectcapture:wartortle-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:blastoise", "displayName": "Blastoise", "catalogNumber": 9, "form": "STAGE_2", "elements": ["WATER"], + "captureDifficulty": 0.75, "baseStats": { "vitality": 80, "power": 76, "guard": 102, "agility": 66 }, + "basicMoveId": "projectcapture:water-jet", "commandMoveIds": ["projectcapture:shell-guard", "projectcapture:mist-surge", "projectcapture:guard-stance"], + "assetId": "projectcapture:blastoise-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:pikachu", "displayName": "Pikachu", "catalogNumber": 25, "form": "BASE", "elements": ["ELECTRIC"], + "captureDifficulty": 0.30, "baseStats": { "vitality": 36, "power": 54, "guard": 34, "agility": 92 }, + "basicMoveId": "projectcapture:static-spark", "commandMoveIds": ["projectcapture:thunder-arc", "projectcapture:quick-step", "projectcapture:guard-stance"], + "evolution": { "method": "ITEM", "targetSpeciesId": "projectcapture:raichu", "itemId": "projectcapture:thunder-core" }, + "assetId": "projectcapture:pikachu-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:raichu", "displayName": "Raichu", "catalogNumber": 26, "form": "STAGE_1", "elements": ["ELECTRIC"], + "captureDifficulty": 0.61, "baseStats": { "vitality": 60, "power": 84, "guard": 56, "agility": 108 }, + "basicMoveId": "projectcapture:static-spark", "commandMoveIds": ["projectcapture:thunder-arc", "projectcapture:quick-step", "projectcapture:guard-stance"], + "assetId": "projectcapture:raichu-assets", "unofficialFanContent": true + }, + { + "id": "projectcapture:ditto", "displayName": "Ditto", "catalogNumber": 132, "form": "SINGLE", "elements": ["NORMAL"], + "captureDifficulty": 0.52, "baseStats": { "vitality": 48, "power": 48, "guard": 48, "agility": 48 }, + "basicMoveId": "projectcapture:morph-strike", "commandMoveIds": ["projectcapture:mirror-form", "projectcapture:guard-stance", "projectcapture:quick-step"], + "assetId": "projectcapture:ditto-assets", "unofficialFanContent": true + } + ] +} diff --git a/content-tools/README.md b/content-tools/README.md new file mode 100644 index 0000000..49b7dcf --- /dev/null +++ b/content-tools/README.md @@ -0,0 +1,10 @@ +# Content tools + +`content-tools` is a Java 25 command-line validator for the ProjectCapture metadata catalogs. + +Commands: + +- `validate ` checks schema versions, stable IDs, references, evolution graphs, level and party metadata, spawn coverage, recipes and safe asset paths. +- `digest ` prints a deterministic SHA-256 digest for reproducible review records. + +The catalog must declare itself unofficial, non-commercial and subject to rights review. No Gradle publishing plugin, repository or upload target is configured here. diff --git a/content-tools/build.gradle.kts b/content-tools/build.gradle.kts new file mode 100644 index 0000000..72b5fc2 --- /dev/null +++ b/content-tools/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + java + application +} + +repositories { + mavenCentral() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +dependencies { + implementation("com.fasterxml.jackson.core:jackson-databind:2.20.0") + + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +application { + mainClass = "dev.projectcapture.contenttools.ContentTool" +} + +val fanContentRoot = rootProject.layout.projectDirectory + .dir("content-pokemon/src/main/resources/projectcapture/content/v1") + +tasks.test { + useJUnitPlatform() + systemProperty("projectCapture.contentRoot", fanContentRoot.asFile.absolutePath) +} + +tasks.register("validateFanContent") { + group = "verification" + description = "Validates the unofficial, non-commercial fan-content catalog." + classpath = sourceSets.main.get().runtimeClasspath + mainClass = application.mainClass + args("validate", fanContentRoot.asFile.absolutePath) +} + +tasks.named("check") { + dependsOn("validateFanContent") +} diff --git a/content-tools/src/main/java/dev/projectcapture/contenttools/ContentBundle.java b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentBundle.java new file mode 100644 index 0000000..c61243e --- /dev/null +++ b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentBundle.java @@ -0,0 +1,112 @@ +package dev.projectcapture.contenttools; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ContentBundle { + public static final int CURRENT_SCHEMA_VERSION = 1; + public static final List REQUIRED_DOCUMENTS = List.of( + "manifest.json", + "moves.json", + "species.json", + "spawn-rules.json", + "recipes.json", + "assets.json" + ); + + private static final ObjectMapper JSON = new ObjectMapper() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + + private final Path root; + private final Map documents; + + private ContentBundle(Path root, Map documents) { + this.root = root; + this.documents = Map.copyOf(documents); + } + + public static ContentBundle load(Path requestedRoot) throws IOException { + Path root = requestedRoot.toAbsolutePath().normalize(); + if (!Files.isDirectory(root)) { + throw new IOException("Content root is not a directory: " + root); + } + + Map documents = new LinkedHashMap<>(); + for (String name : REQUIRED_DOCUMENTS) { + Path document = root.resolve(name).normalize(); + if (!document.startsWith(root)) { + throw new IOException("Document escaped content root: " + name); + } + if (!Files.isRegularFile(document)) { + throw new IOException("Required content document is missing: " + document); + } + JsonNode parsed = JSON.readTree(document.toFile()); + if (parsed == null || !parsed.isObject()) { + throw new IOException("Content document must contain one JSON object: " + document); + } + documents.put(name, parsed); + } + return new ContentBundle(root, documents); + } + + public Path root() { + return root; + } + + public JsonNode document(String name) { + JsonNode node = documents.get(name); + if (node == null) { + throw new IllegalArgumentException("Unknown content document: " + name); + } + return node; + } + + public Map documents() { + return documents; + } + + public String digest() { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + REQUIRED_DOCUMENTS.stream().sorted().forEach(name -> { + try { + digest.update(name.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(Files.readAllBytes(root.resolve(name))); + digest.update((byte) 0); + } catch (IOException exception) { + throw new DigestReadException(exception); + } + }); + return "sha256:" + HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } catch (DigestReadException exception) { + throw new IllegalStateException("Could not calculate content digest", exception.getCause()); + } + } + + static ObjectMapper json() { + return JSON; + } + + private static final class DigestReadException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private DigestReadException(IOException cause) { + super(cause); + } + } +} diff --git a/content-tools/src/main/java/dev/projectcapture/contenttools/ContentTool.java b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentTool.java new file mode 100644 index 0000000..6b06d1b --- /dev/null +++ b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentTool.java @@ -0,0 +1,67 @@ +package dev.projectcapture.contenttools; + +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Path; + +public final class ContentTool { + private ContentTool() {} + + public static void main(String[] args) { + int exitCode = run(args, System.out, System.err); + if (exitCode != 0) { + System.exit(exitCode); + } + } + + static int run(String[] args, PrintStream out, PrintStream err) { + if (args.length != 2) { + printUsage(err); + return 64; + } + + Path contentRoot = Path.of(args[1]).toAbsolutePath().normalize(); + return switch (args[0]) { + case "validate" -> validate(contentRoot, out, err); + case "digest" -> digest(contentRoot, out, err); + default -> { + err.println("Unknown command: " + args[0]); + printUsage(err); + yield 64; + } + }; + } + + private static int validate(Path contentRoot, PrintStream out, PrintStream err) { + ValidationReport report = new ContentValidator().validate(contentRoot); + if (!report.isValid()) { + err.println("Content validation failed with " + report.issues().size() + " issue(s):"); + report.issues().forEach(issue -> err.println("- " + issue)); + return 2; + } + out.println("Content validation passed: " + contentRoot); + return 0; + } + + private static int digest(Path contentRoot, PrintStream out, PrintStream err) { + ValidationReport report = new ContentValidator().validate(contentRoot); + if (!report.isValid()) { + err.println("Digest refused because content validation failed:"); + report.issues().forEach(issue -> err.println("- " + issue)); + return 2; + } + try { + out.println(ContentBundle.load(contentRoot).digest()); + return 0; + } catch (IOException | RuntimeException exception) { + err.println("Could not calculate digest: " + exception.getMessage()); + return 2; + } + } + + private static void printUsage(PrintStream stream) { + stream.println("Usage:"); + stream.println(" content-tools validate "); + stream.println(" content-tools digest "); + } +} diff --git a/content-tools/src/main/java/dev/projectcapture/contenttools/ContentValidator.java b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentValidator.java new file mode 100644 index 0000000..55b7088 --- /dev/null +++ b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentValidator.java @@ -0,0 +1,626 @@ +package dev.projectcapture.contenttools; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; + +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +public final class ContentValidator { + private static final Pattern STABLE_ID = Pattern.compile("^[a-z][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$"); + private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9A-Fa-f]{6}$"); + + static final Set UNOFFICIAL_SLICE_SPECIES = Set.of( + "projectcapture:bulbasaur", + "projectcapture:ivysaur", + "projectcapture:venusaur", + "projectcapture:charmander", + "projectcapture:charmeleon", + "projectcapture:charizard", + "projectcapture:squirtle", + "projectcapture:wartortle", + "projectcapture:blastoise", + "projectcapture:pikachu", + "projectcapture:raichu", + "projectcapture:ditto" + ); + + public ValidationReport validate(Path root) { + ValidationReport report = new ValidationReport(); + try { + report.addAll(validate(ContentBundle.load(root))); + } catch (IOException | RuntimeException exception) { + report.add("CONTENT_LOAD", root.toString(), exception.getMessage()); + } + return report; + } + + public ValidationReport validate(ContentBundle bundle) { + ValidationReport report = new ValidationReport(); + bundle.documents().forEach((name, document) -> validateDocumentHeader(name, document, report)); + + JsonNode manifest = bundle.document("manifest.json"); + Set expectedSpecies = validateManifest(manifest, report); + Map moveRoles = validateMoves(bundle.document("moves.json"), report); + Set producedItems = validateRecipes(bundle.document("recipes.json"), report); + AssetIndex assets = validateAssets(bundle.document("assets.json"), report); + SpeciesIndex species = validateSpecies( + bundle.document("species.json"), moveRoles, producedItems, assets, report + ); + + if (!species.ids.equals(expectedSpecies)) { + Set missing = new LinkedHashSet<>(expectedSpecies); + missing.removeAll(species.ids); + Set unexpected = new LinkedHashSet<>(species.ids); + unexpected.removeAll(expectedSpecies); + report.add( + "SPECIES_SET", + "species.json.items", + "Species must exactly match manifest.expectedSpeciesIds; missing=" + missing + ", unexpected=" + unexpected + ); + } + if ("UNOFFICIAL_TWELVE_FORM_SLICE".equals(text(manifest, "contentProfile")) + && !expectedSpecies.equals(UNOFFICIAL_SLICE_SPECIES)) { + report.add( + "UNOFFICIAL_SLICE", + "manifest.json.expectedSpeciesIds", + "The unofficial twelve-form profile must contain exactly the selected twelve development forms" + ); + } + + validateEvolutionGraph(species.evolutionTargets, report); + validateAssetCoverage(species.ids, assets, report); + validateSpawns(bundle.document("spawn-rules.json"), species.ids, report); + return report; + } + + private void validateDocumentHeader(String name, JsonNode document, ValidationReport report) { + if (document.path("schemaVersion").asInt(-1) != ContentBundle.CURRENT_SCHEMA_VERSION) { + report.add( + "SCHEMA_VERSION", + name + ".schemaVersion", + "Expected schemaVersion " + ContentBundle.CURRENT_SCHEMA_VERSION + ); + } + if (!document.path("unofficialFanContent").isBoolean() || !document.path("unofficialFanContent").asBoolean()) { + report.add( + "FAN_CONTENT_MARKER", + name + ".unofficialFanContent", + "Fan-content documents must be explicitly marked unofficialFanContent=true" + ); + } + } + + private Set validateManifest(JsonNode manifest, ValidationReport report) { + requireId(manifest, "contentSetId", "manifest.json.contentSetId", report); + requireText(manifest, "contentProfile", "manifest.json.contentProfile", report); + if (!"UNOFFICIAL_FAN_CONTENT".equals(text(manifest, "publicationStatus"))) { + report.add( + "PUBLICATION_STATUS", + "manifest.json.publicationStatus", + "Fan-content catalog must declare UNOFFICIAL_FAN_CONTENT" + ); + } + if (!manifest.path("nonCommercial").asBoolean(false)) { + report.add("NON_COMMERCIAL", "manifest.json.nonCommercial", "Fan-content catalog must remain non-commercial"); + } + if (!manifest.path("rightsReviewRequired").asBoolean(false)) { + report.add( + "RIGHTS_REVIEW", + "manifest.json.rightsReviewRequired", + "Fan-content catalog must require an explicit rights review" + ); + } + + JsonNode gameplay = manifest.path("gameplay"); + requireExactInt(gameplay, "minimumLevel", 1, "manifest.json.gameplay", report); + requireExactInt(gameplay, "maximumLevel", 100, "manifest.json.gameplay", report); + requireExactInt(gameplay, "maximumPartySize", 6, "manifest.json.gameplay", report); + requireExactInt(gameplay, "visibleCompanionLimit", 1, "manifest.json.gameplay", report); + requireExactInt(gameplay, "commandedMoveSlots", 3, "manifest.json.gameplay", report); + if (!gameplay.path("starterGrantEnabled").isBoolean() || gameplay.path("starterGrantEnabled").asBoolean()) { + report.add( + "STARTER_POLICY", + "manifest.json.gameplay.starterGrantEnabled", + "The slice grants no starter; only capture-device crafting is unlocked" + ); + } + + Set expected = new LinkedHashSet<>(); + JsonNode list = manifest.path("expectedSpeciesIds"); + if (!list.isArray() || list.isEmpty()) { + report.add("EXPECTED_SPECIES", "manifest.json.expectedSpeciesIds", "Expected species list must be non-empty"); + return expected; + } + for (int index = 0; index < list.size(); index++) { + String location = "manifest.json.expectedSpeciesIds[" + index + "]"; + String id = list.path(index).asText(""); + validateIdValue(id, location, report); + if (!expected.add(id)) { + report.add("DUPLICATE_ID", location, "Duplicate expected species ID: " + id); + } + } + return expected; + } + + private Map validateMoves(JsonNode document, ValidationReport report) { + Map roles = new LinkedHashMap<>(); + ArrayNode items = requireItems(document, "moves.json", report); + for (int index = 0; index < items.size(); index++) { + JsonNode move = items.path(index); + String location = "moves.json.items[" + index + "]"; + requireUnofficialItem(move, location, report); + String id = requireId(move, "id", location + ".id", report); + if (!id.isEmpty() && roles.containsKey(id)) { + report.add("DUPLICATE_ID", location + ".id", "Duplicate move ID: " + id); + } + requireText(move, "name", location + ".name", report); + requireEnum(move, "element", Set.of("NORMAL", "GRASS", "TOXIN", "FIRE", "AIR", "WATER", "ELECTRIC"), location, report); + String role = requireEnum(move, "role", Set.of("BASIC", "COMMAND"), location, report); + requireNumberRange(move, "power", 0, 250, location, report); + requireNumberRange(move, "cooldownSeconds", 0.1, 120, location, report); + if (!id.isEmpty()) { + roles.putIfAbsent(id, role); + } + } + if (roles.isEmpty()) { + report.add("MOVE_EMPTY", "moves.json.items", "At least one move is required"); + } + return roles; + } + + private Set validateRecipes(JsonNode document, ValidationReport report) { + Set ids = new HashSet<>(); + Set outputs = new LinkedHashSet<>(); + boolean unlockedCaptureDevice = false; + ArrayNode items = requireItems(document, "recipes.json", report); + for (int index = 0; index < items.size(); index++) { + JsonNode recipe = items.path(index); + String location = "recipes.json.items[" + index + "]"; + requireUnofficialItem(recipe, location, report); + String id = requireId(recipe, "id", location + ".id", report); + if (!id.isEmpty() && !ids.add(id)) { + report.add("DUPLICATE_ID", location + ".id", "Duplicate recipe ID: " + id); + } + String category = requireText(recipe, "category", location + ".category", report); + JsonNode output = recipe.path("output"); + String outputId = requireId(output, "itemId", location + ".output.itemId", report); + requireIntegerRange(output, "quantity", 1, 999, location + ".output", report); + if (!outputId.isEmpty()) { + outputs.add(outputId); + } + JsonNode ingredients = recipe.path("ingredients"); + if (!ingredients.isArray() || ingredients.isEmpty()) { + report.add("RECIPE_INGREDIENTS", location + ".ingredients", "Recipe ingredients must be non-empty"); + } else { + for (int ingredientIndex = 0; ingredientIndex < ingredients.size(); ingredientIndex++) { + JsonNode ingredient = ingredients.path(ingredientIndex); + String ingredientLocation = location + ".ingredients[" + ingredientIndex + "]"; + requireId(ingredient, "itemId", ingredientLocation + ".itemId", report); + requireIntegerRange(ingredient, "quantity", 1, 999, ingredientLocation, report); + } + } + if ("CAPTURE_DEVICE".equals(category) && recipe.path("unlockedByDefault").asBoolean(false)) { + unlockedCaptureDevice = true; + } + } + if (!unlockedCaptureDevice) { + report.add( + "CAPTURE_RECIPE", + "recipes.json.items", + "At least one capture-device recipe must be unlocked by default" + ); + } + return outputs; + } + + private AssetIndex validateAssets(JsonNode document, ValidationReport report) { + Set ids = new LinkedHashSet<>(); + Map speciesByAsset = new LinkedHashMap<>(); + Map countBySpecies = new HashMap<>(); + ArrayNode items = requireItems(document, "assets.json", report); + for (int index = 0; index < items.size(); index++) { + JsonNode asset = items.path(index); + String location = "assets.json.items[" + index + "]"; + requireUnofficialItem(asset, location, report); + String id = requireId(asset, "id", location + ".id", report); + String speciesId = requireId(asset, "speciesId", location + ".speciesId", report); + if (!id.isEmpty() && !ids.add(id)) { + report.add("DUPLICATE_ID", location + ".id", "Duplicate asset ID: " + id); + } + if (!id.isEmpty()) { + speciesByAsset.putIfAbsent(id, speciesId); + } + if (!speciesId.isEmpty()) { + countBySpecies.merge(speciesId, 1, Integer::sum); + } + requireEnum(asset, "status", Set.of("PLANNED", "READY"), location, report); + requireIntegerRange(asset, "seed", 0, Integer.MAX_VALUE, location, report); + requireText(asset, "bodyPlan", location + ".bodyPlan", report); + requireNonEmptyTextArray(asset.path("sockets"), location + ".sockets", report); + + JsonNode palette = asset.path("palette"); + if (!palette.isArray() || palette.size() < 2) { + report.add("ASSET_PALETTE", location + ".palette", "Asset palette needs at least two colors"); + } else { + for (int paletteIndex = 0; paletteIndex < palette.size(); paletteIndex++) { + if (!HEX_COLOR.matcher(palette.path(paletteIndex).asText("")).matches()) { + report.add("ASSET_PALETTE", location + ".palette[" + paletteIndex + "]", "Expected #RRGGBB"); + } + } + } + + validateAssetPath(asset, "source", ".bbmodel", location, report); + validateAssetPath(asset, "model", ".blockymodel", location, report); + validateAssetPath(asset, "texture", ".png", location, report); + validateAssetPath(asset, "recipe", ".asset.json", location, report); + JsonNode animations = asset.path("animations"); + if (!animations.isObject()) { + report.add("ASSET_ANIMATIONS", location + ".animations", "Animations must be an object"); + } else { + for (String animation : List.of("idle", "walk", "attack")) { + validateAssetPath(animations, animation, ".blockyanim", location + ".animations", report); + } + } + } + return new AssetIndex(ids, speciesByAsset, countBySpecies); + } + + private SpeciesIndex validateSpecies( + JsonNode document, + Map moveRoles, + Set producedItems, + AssetIndex assets, + ValidationReport report + ) { + Set ids = new LinkedHashSet<>(); + Set catalogNumbers = new HashSet<>(); + Map evolutionTargets = new LinkedHashMap<>(); + List pending = new ArrayList<>(); + ArrayNode items = requireItems(document, "species.json", report); + + for (int index = 0; index < items.size(); index++) { + JsonNode species = items.path(index); + String location = "species.json.items[" + index + "]"; + requireUnofficialItem(species, location, report); + String id = requireId(species, "id", location + ".id", report); + if (!id.isEmpty() && !ids.add(id)) { + report.add("DUPLICATE_ID", location + ".id", "Duplicate species ID: " + id); + } + requireText(species, "displayName", location + ".displayName", report); + int catalogNumber = requireIntegerRange(species, "catalogNumber", 1, 9999, location, report); + if (catalogNumber >= 0 && !catalogNumbers.add(catalogNumber)) { + report.add("DUPLICATE_CATALOG_NUMBER", location + ".catalogNumber", "Duplicate catalog number: " + catalogNumber); + } + requireEnum(species, "form", Set.of("BASE", "STAGE_1", "STAGE_2", "SINGLE"), location, report); + requireNonEmptyTextArray(species.path("elements"), location + ".elements", report); + requireNumberRange(species, "captureDifficulty", 0.01, 0.99, location, report); + JsonNode stats = species.path("baseStats"); + for (String stat : List.of("vitality", "power", "guard", "agility")) { + requireIntegerRange(stats, stat, 1, 255, location + ".baseStats", report); + } + + String basicMove = requireId(species, "basicMoveId", location + ".basicMoveId", report); + if (!basicMove.isEmpty() && !moveRoles.containsKey(basicMove)) { + report.add("UNKNOWN_MOVE", location + ".basicMoveId", "Unknown move: " + basicMove); + } else if (!basicMove.isEmpty() && !"BASIC".equals(moveRoles.get(basicMove))) { + report.add("MOVE_ROLE", location + ".basicMoveId", "Basic move must have role BASIC"); + } + List commandMoves = validateCommandMoves(species.path("commandMoveIds"), moveRoles, location, report); + if (commandMoves.contains(basicMove)) { + report.add("MOVE_ROLE", location + ".commandMoveIds", "Basic move cannot also occupy a command slot"); + } + + String assetId = requireId(species, "assetId", location + ".assetId", report); + if (!assetId.isEmpty() && !assets.ids.contains(assetId)) { + report.add("UNKNOWN_ASSET", location + ".assetId", "Unknown asset record: " + assetId); + } + + JsonNode evolution = species.path("evolution"); + String target = ""; + if (!evolution.isMissingNode() && !evolution.isNull()) { + if (!evolution.isObject()) { + report.add("EVOLUTION_SHAPE", location + ".evolution", "Evolution must be an object"); + } else { + String method = requireEnum(evolution, "method", Set.of("LEVEL", "ITEM"), location + ".evolution", report); + target = requireId(evolution, "targetSpeciesId", location + ".evolution.targetSpeciesId", report); + if ("LEVEL".equals(method)) { + requireIntegerRange(evolution, "minimumLevel", 2, 100, location + ".evolution", report); + } else if ("ITEM".equals(method)) { + String itemId = requireId(evolution, "itemId", location + ".evolution.itemId", report); + if (!itemId.isEmpty() && !producedItems.contains(itemId)) { + report.add("UNKNOWN_EVOLUTION_ITEM", location + ".evolution.itemId", "No recipe produces " + itemId); + } + } + if (!id.isEmpty() && !target.isEmpty()) { + evolutionTargets.put(id, target); + } + } + } + pending.add(new PendingSpeciesReferences(location, id, target, assetId)); + } + + for (PendingSpeciesReferences reference : pending) { + if (!reference.target.isEmpty() && !ids.contains(reference.target)) { + report.add("UNKNOWN_EVOLUTION_TARGET", reference.location + ".evolution.targetSpeciesId", "Unknown species: " + reference.target); + } + if (!reference.assetId.isEmpty()) { + String assetSpecies = assets.speciesByAsset.get(reference.assetId); + if (assetSpecies != null && !assetSpecies.equals(reference.id)) { + report.add("ASSET_SPECIES_MISMATCH", reference.location + ".assetId", "Asset belongs to " + assetSpecies); + } + } + } + return new SpeciesIndex(ids, evolutionTargets); + } + + private List validateCommandMoves( + JsonNode moves, + Map moveRoles, + String location, + ValidationReport report + ) { + List result = new ArrayList<>(); + if (!moves.isArray() || moves.size() != 3) { + report.add("COMMAND_SLOTS", location + ".commandMoveIds", "Exactly three commanded moves are required"); + return result; + } + Set unique = new HashSet<>(); + for (int index = 0; index < moves.size(); index++) { + String move = moves.path(index).asText(""); + String moveLocation = location + ".commandMoveIds[" + index + "]"; + validateIdValue(move, moveLocation, report); + if (!unique.add(move)) { + report.add("DUPLICATE_COMMAND_MOVE", moveLocation, "Command moves must be unique"); + } + if (!moveRoles.containsKey(move)) { + report.add("UNKNOWN_MOVE", moveLocation, "Unknown move: " + move); + } else if (!"COMMAND".equals(moveRoles.get(move))) { + report.add("MOVE_ROLE", moveLocation, "Command move must have role COMMAND"); + } + result.add(move); + } + return result; + } + + private void validateEvolutionGraph(Map targets, ValidationReport report) { + Map state = new HashMap<>(); + for (String species : targets.keySet()) { + visitEvolution(species, targets, state, new ArrayList<>(), report); + } + } + + private void visitEvolution( + String species, + Map targets, + Map state, + List path, + ValidationReport report + ) { + int currentState = state.getOrDefault(species, 0); + if (currentState == 2) { + return; + } + if (currentState == 1) { + path.add(species); + report.add("EVOLUTION_CYCLE", "species.json", "Evolution cycle: " + String.join(" -> ", path)); + return; + } + state.put(species, 1); + path.add(species); + String target = targets.get(species); + if (target != null) { + visitEvolution(target, targets, state, new ArrayList<>(path), report); + } + state.put(species, 2); + } + + private void validateAssetCoverage(Set species, AssetIndex assets, ValidationReport report) { + for (String speciesId : species) { + int count = assets.countBySpecies.getOrDefault(speciesId, 0); + if (count != 1) { + report.add("ASSET_COVERAGE", "assets.json.items", speciesId + " must have exactly one asset record; found " + count); + } + } + for (String assetSpecies : assets.countBySpecies.keySet()) { + if (!species.contains(assetSpecies)) { + report.add("UNKNOWN_ASSET_SPECIES", "assets.json.items", "Asset references unknown species: " + assetSpecies); + } + } + } + + private void validateSpawns(JsonNode document, Set species, ValidationReport report) { + Set ids = new HashSet<>(); + Map countBySpecies = new HashMap<>(); + ArrayNode items = requireItems(document, "spawn-rules.json", report); + for (int index = 0; index < items.size(); index++) { + JsonNode spawn = items.path(index); + String location = "spawn-rules.json.items[" + index + "]"; + requireUnofficialItem(spawn, location, report); + String id = requireId(spawn, "id", location + ".id", report); + if (!id.isEmpty() && !ids.add(id)) { + report.add("DUPLICATE_ID", location + ".id", "Duplicate spawn ID: " + id); + } + String speciesId = requireId(spawn, "speciesId", location + ".speciesId", report); + if (!speciesId.isEmpty() && !species.contains(speciesId)) { + report.add("UNKNOWN_SPAWN_SPECIES", location + ".speciesId", "Unknown species: " + speciesId); + } + countBySpecies.merge(speciesId, 1, Integer::sum); + requireNonEmptyTextArray(spawn.path("biomes"), location + ".biomes", report); + requireEnum(spawn, "time", Set.of("DAY", "NIGHT", "ANY"), location, report); + requireIntegerRange(spawn, "rarityWeight", 1, 100, location, report); + int minimum = requireIntegerRange(spawn, "minimumLevel", 1, 100, location, report); + int maximum = requireIntegerRange(spawn, "maximumLevel", 1, 100, location, report); + if (minimum >= 0 && maximum >= 0 && minimum > maximum) { + report.add("SPAWN_LEVEL_RANGE", location, "minimumLevel must not exceed maximumLevel"); + } + } + for (String speciesId : species) { + if (countBySpecies.getOrDefault(speciesId, 0) < 1) { + report.add("SPAWN_COVERAGE", "spawn-rules.json.items", "No spawn rule for " + speciesId); + } + } + } + + private void validateAssetPath( + JsonNode node, + String field, + String extension, + String location, + ValidationReport report + ) { + String value = requireText(node, field, location + "." + field, report); + if (value.isEmpty()) { + return; + } + if (value.contains("\\") || value.contains("://") || value.matches("^[A-Za-z]:.*")) { + report.add("ASSET_PATH", location + "." + field, "Asset path must be a portable relative path"); + return; + } + try { + Path path = Path.of(value); + String normalized = path.normalize().toString().replace('\\', '/'); + if (path.isAbsolute() || !normalized.equals(value) || value.contains("../") || !value.startsWith("assets/fan/")) { + report.add("ASSET_PATH", location + "." + field, "Asset path must stay below assets/fan without traversal"); + } + } catch (InvalidPathException exception) { + report.add("ASSET_PATH", location + "." + field, "Invalid asset path: " + exception.getMessage()); + } + if (!value.endsWith(extension)) { + report.add("ASSET_EXTENSION", location + "." + field, "Expected extension " + extension); + } + } + + private ArrayNode requireItems(JsonNode document, String name, ValidationReport report) { + JsonNode items = document.path("items"); + if (!items.isArray()) { + report.add("ITEMS_SHAPE", name + ".items", "Expected an array"); + return ContentBundle.json().createArrayNode(); + } + return (ArrayNode) items; + } + + private void requireUnofficialItem(JsonNode item, String location, ValidationReport report) { + if (!item.path("unofficialFanContent").isBoolean() || !item.path("unofficialFanContent").asBoolean()) { + report.add("FAN_CONTENT_MARKER", location + ".unofficialFanContent", "Fan-content entries must be explicitly marked"); + } + } + + private String requireId(JsonNode node, String field, String location, ValidationReport report) { + String value = requireText(node, field, location, report); + if (!value.isEmpty()) { + validateIdValue(value, location, report); + } + return value; + } + + private void validateIdValue(String value, String location, ValidationReport report) { + if (!STABLE_ID.matcher(value).matches()) { + report.add("INVALID_ID", location, "Expected lowercase namespace:value stable ID"); + } + } + + private String requireText(JsonNode node, String field, String location, ValidationReport report) { + JsonNode value = node.path(field); + if (!value.isTextual() || value.asText().isBlank()) { + report.add("REQUIRED_TEXT", location, "Expected non-blank text"); + return ""; + } + return value.asText(); + } + + private String requireEnum(JsonNode node, String field, Set allowed, String location, ValidationReport report) { + String value = requireText(node, field, location + "." + field, report); + if (!value.isEmpty() && !allowed.contains(value)) { + report.add("ENUM_VALUE", location + "." + field, "Expected one of " + allowed + ", got " + value); + } + return value; + } + + private int requireIntegerRange( + JsonNode node, + String field, + int minimum, + int maximum, + String location, + ValidationReport report + ) { + JsonNode value = node.path(field); + if (!value.isIntegralNumber() || !value.canConvertToInt()) { + report.add("INTEGER_VALUE", location + "." + field, "Expected an integer"); + return -1; + } + int number = value.asInt(); + if (number < minimum || number > maximum) { + report.add("INTEGER_RANGE", location + "." + field, "Expected " + minimum + ".." + maximum); + } + return number; + } + + private void requireNumberRange( + JsonNode node, + String field, + double minimum, + double maximum, + String location, + ValidationReport report + ) { + JsonNode value = node.path(field); + if (!value.isNumber()) { + report.add("NUMBER_VALUE", location + "." + field, "Expected a number"); + return; + } + double number = value.asDouble(); + if (!Double.isFinite(number) || number < minimum || number > maximum) { + report.add("NUMBER_RANGE", location + "." + field, "Expected " + minimum + ".." + maximum); + } + } + + private void requireExactInt(JsonNode node, String field, int expected, String location, ValidationReport report) { + JsonNode value = node.path(field); + if (!value.isIntegralNumber() || value.asInt() != expected) { + report.add("GAMEPLAY_METADATA", location + "." + field, "Expected " + expected); + } + } + + private void requireNonEmptyTextArray(JsonNode node, String location, ValidationReport report) { + if (!node.isArray() || node.isEmpty()) { + report.add("TEXT_ARRAY", location, "Expected a non-empty text array"); + return; + } + Set values = new HashSet<>(); + for (int index = 0; index < node.size(); index++) { + JsonNode value = node.path(index); + if (!value.isTextual() || value.asText().isBlank()) { + report.add("TEXT_ARRAY", location + "[" + index + "]", "Expected non-blank text"); + } else if (!values.add(value.asText())) { + report.add("TEXT_ARRAY_DUPLICATE", location + "[" + index + "]", "Duplicate value: " + value.asText()); + } + } + } + + private static String text(JsonNode node, String field) { + return node.path(field).isTextual() ? node.path(field).asText() : ""; + } + + private record AssetIndex( + Set ids, + Map speciesByAsset, + Map countBySpecies + ) {} + + private record SpeciesIndex(Set ids, Map evolutionTargets) {} + + private record PendingSpeciesReferences(String location, String id, String target, String assetId) {} +} diff --git a/content-tools/src/main/java/dev/projectcapture/contenttools/ValidationIssue.java b/content-tools/src/main/java/dev/projectcapture/contenttools/ValidationIssue.java new file mode 100644 index 0000000..04149cc --- /dev/null +++ b/content-tools/src/main/java/dev/projectcapture/contenttools/ValidationIssue.java @@ -0,0 +1,8 @@ +package dev.projectcapture.contenttools; + +public record ValidationIssue(String code, String location, String message) { + @Override + public String toString() { + return code + " at " + location + ": " + message; + } +} diff --git a/content-tools/src/main/java/dev/projectcapture/contenttools/ValidationReport.java b/content-tools/src/main/java/dev/projectcapture/contenttools/ValidationReport.java new file mode 100644 index 0000000..44c856b --- /dev/null +++ b/content-tools/src/main/java/dev/projectcapture/contenttools/ValidationReport.java @@ -0,0 +1,29 @@ +package dev.projectcapture.contenttools; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class ValidationReport { + private final List issues = new ArrayList<>(); + + public void add(String code, String location, String message) { + issues.add(new ValidationIssue(code, location, message)); + } + + public void addAll(ValidationReport other) { + issues.addAll(other.issues); + } + + public boolean isValid() { + return issues.isEmpty(); + } + + public List issues() { + return Collections.unmodifiableList(issues); + } + + public boolean containsCode(String code) { + return issues.stream().anyMatch(issue -> issue.code().equals(code)); + } +} diff --git a/content-tools/src/test/java/dev/projectcapture/contenttools/ContentToolTest.java b/content-tools/src/test/java/dev/projectcapture/contenttools/ContentToolTest.java new file mode 100644 index 0000000..4f2f133 --- /dev/null +++ b/content-tools/src/test/java/dev/projectcapture/contenttools/ContentToolTest.java @@ -0,0 +1,43 @@ +package dev.projectcapture.contenttools; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ContentToolTest { + @Test + void validateCommandSucceedsForCatalog() { + Path root = Path.of(System.getProperty("projectCapture.contentRoot")); + ByteArrayOutputStream standardOut = new ByteArrayOutputStream(); + ByteArrayOutputStream standardError = new ByteArrayOutputStream(); + + int exitCode = ContentTool.run( + new String[] { "validate", root.toString() }, + new PrintStream(standardOut, true, StandardCharsets.UTF_8), + new PrintStream(standardError, true, StandardCharsets.UTF_8) + ); + + assertEquals(0, exitCode, standardError::toString); + assertTrue(standardOut.toString(StandardCharsets.UTF_8).contains("Content validation passed")); + } + + @Test + void unknownCommandReturnsUsageError() { + ByteArrayOutputStream error = new ByteArrayOutputStream(); + + int exitCode = ContentTool.run( + new String[] { "publish", "." }, + new PrintStream(new ByteArrayOutputStream()), + new PrintStream(error) + ); + + assertEquals(64, exitCode); + assertTrue(error.toString(StandardCharsets.UTF_8).contains("Unknown command")); + } +} diff --git a/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java b/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java new file mode 100644 index 0000000..48a9bf2 --- /dev/null +++ b/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java @@ -0,0 +1,171 @@ +package dev.projectcapture.contenttools; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ContentValidatorTest { + @TempDir + Path temporaryDirectory; + + private Path contentRoot; + + @BeforeEach + void copyRealCatalog() throws IOException { + Path source = Path.of(System.getProperty("projectCapture.contentRoot")); + contentRoot = temporaryDirectory.resolve("content"); + Files.createDirectories(contentRoot); + for (String document : ContentBundle.REQUIRED_DOCUMENTS) { + Files.copy(source.resolve(document), contentRoot.resolve(document)); + } + } + + @Test + void realTwelveFormCatalogPasses() throws IOException { + ValidationReport report = new ContentValidator().validate(contentRoot); + + assertTrue(report.isValid(), () -> report.issues().toString()); + JsonNode species = ContentBundle.load(contentRoot).document("species.json").path("items"); + Set ids = java.util.stream.StreamSupport.stream(species.spliterator(), false) + .map(item -> item.path("id").asText()) + .collect(Collectors.toSet()); + assertEquals(12, ids.size()); + assertEquals(ContentValidator.UNOFFICIAL_SLICE_SPECIES, ids); + } + + @Test + void rejectsWrongSchemaAndDuplicateIds() throws IOException { + ObjectNode moves = object("moves.json"); + moves.put("schemaVersion", 99); + ArrayNode items = (ArrayNode) moves.path("items"); + items.add(items.path(0).deepCopy()); + write("moves.json", moves); + + ValidationReport report = new ContentValidator().validate(contentRoot); + + assertTrue(report.containsCode("SCHEMA_VERSION")); + assertTrue(report.containsCode("DUPLICATE_ID")); + } + + @Test + void rejectsUnknownMoveEvolutionAndAssetReferences() throws IOException { + ObjectNode species = object("species.json"); + ObjectNode first = (ObjectNode) species.path("items").path(0); + first.put("basicMoveId", "projectcapture:missing-move"); + first.put("assetId", "projectcapture:missing-assets"); + ((ObjectNode) first.path("evolution")).put("targetSpeciesId", "projectcapture:missing-species"); + write("species.json", species); + + ValidationReport report = new ContentValidator().validate(contentRoot); + + assertTrue(report.containsCode("UNKNOWN_MOVE")); + assertTrue(report.containsCode("UNKNOWN_ASSET")); + assertTrue(report.containsCode("UNKNOWN_EVOLUTION_TARGET")); + } + + @Test + void rejectsEvolutionCycles() throws IOException { + ObjectNode species = object("species.json"); + ObjectNode venusaur = findItem(species, "projectcapture:venusaur"); + ObjectNode evolution = venusaur.putObject("evolution"); + evolution.put("method", "LEVEL"); + evolution.put("targetSpeciesId", "projectcapture:bulbasaur"); + evolution.put("minimumLevel", 80); + write("species.json", species); + + ValidationReport report = new ContentValidator().validate(contentRoot); + + assertTrue(report.containsCode("EVOLUTION_CYCLE")); + } + + @Test + void rejectsUnsafeAssetPathsAndMissingAnimationSlots() throws IOException { + ObjectNode assets = object("assets.json"); + ObjectNode first = (ObjectNode) assets.path("items").path(0); + first.put("model", "../../outside.blockymodel"); + ((ObjectNode) first.path("animations")).remove("attack"); + write("assets.json", assets); + + ValidationReport report = new ContentValidator().validate(contentRoot); + + assertTrue(report.containsCode("ASSET_PATH")); + assertTrue(report.containsCode("REQUIRED_TEXT")); + } + + @Test + void rejectsPartyLevelAndRightsMetadataDrift() throws IOException { + ObjectNode manifest = object("manifest.json"); + ObjectNode gameplay = (ObjectNode) manifest.path("gameplay"); + gameplay.put("minimumLevel", 0); + gameplay.put("maximumLevel", 101); + gameplay.put("maximumPartySize", 7); + gameplay.put("visibleCompanionLimit", 2); + gameplay.put("commandedMoveSlots", 4); + gameplay.put("starterGrantEnabled", true); + manifest.put("nonCommercial", false); + manifest.put("rightsReviewRequired", false); + write("manifest.json", manifest); + + ValidationReport report = new ContentValidator().validate(contentRoot); + + assertTrue(report.containsCode("GAMEPLAY_METADATA")); + assertTrue(report.containsCode("STARTER_POLICY")); + assertTrue(report.containsCode("NON_COMMERCIAL")); + assertTrue(report.containsCode("RIGHTS_REVIEW")); + } + + @Test + void rejectsMissingSpawnCoverage() throws IOException { + ObjectNode spawns = object("spawn-rules.json"); + ((ArrayNode) spawns.path("items")).remove(0); + write("spawn-rules.json", spawns); + + ValidationReport report = new ContentValidator().validate(contentRoot); + + assertTrue(report.containsCode("SPAWN_COVERAGE")); + } + + @Test + void digestIsStableAndChangesWithContent() throws IOException { + String first = ContentBundle.load(contentRoot).digest(); + String second = ContentBundle.load(contentRoot).digest(); + assertEquals(first, second); + + ObjectNode recipes = object("recipes.json"); + findItem(recipes, "projectcapture:basic-capture-capsule").put("unlockedByDefault", false); + write("recipes.json", recipes); + + String changed = ContentBundle.load(contentRoot).digest(); + assertFalse(first.equals(changed)); + } + + private ObjectNode object(String name) throws IOException { + return (ObjectNode) ContentBundle.json().readTree(contentRoot.resolve(name).toFile()); + } + + private void write(String name, JsonNode node) throws IOException { + ContentBundle.json().writerWithDefaultPrettyPrinter().writeValue(contentRoot.resolve(name).toFile(), node); + } + + private static ObjectNode findItem(ObjectNode document, String id) { + for (JsonNode item : document.path("items")) { + if (id.equals(item.path("id").asText())) { + return (ObjectNode) item; + } + } + throw new IllegalArgumentException("Missing test fixture item: " + id); + } +} diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 203f120..fbfb422 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -10,7 +10,7 @@ Die technische Basis ist Hytale Stable 0.5.9, Java 25 und Gradle 9.6.1. Das Java - `engine`: Hytale-unabhängige Fang-, Kampf-, Level-, Evolutions-, Zucht-, Genetik-, Handels- und Duelllogik. - `hytale-adapter`: Plugin-Lifecycle, Events, ECS, NPCs, Persistenz, UI, Kamera und Weltintegration. -- `content-private`: vorläufiger Modulname für Spezies-, Attacken-, Spawn-, Rezept- und Asset-Daten; nach dem Foundation-Gate wird er zu `content-pokemon` migriert. +- `content-pokemon`: Spezies-, Attacken-, Spawn-, Rezept- und Asset-Daten für den inoffiziellen Fancontent. - `content-tools`: Schema-, Content-, Asset- und Veröffentlichungsvalidatoren. Die offizielle Abhängigkeit `com.hypixel.hytale:Server:0.5.9` wird exakt gepinnt. Server-JARs, `Assets.zip`, extrahierte Hytale-Basisassets und Geheimnisse werden nie eingecheckt. Hytale-API-Aufrufe bleiben im Adapter, damit spätere API-Versionen austauschbar sind.