feat(content): add twelve-form Pokemon fan catalog

This commit is contained in:
2026-08-15 08:50:44 +02:00
parent 21371ee6e6
commit 098ce5caa4
18 changed files with 1428 additions and 1 deletions
+7
View File
@@ -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.
+31
View File
@@ -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")
}
@@ -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
}
]
}
@@ -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"
]
}
@@ -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 }
]
}
@@ -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
}
]
}
@@ -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 }
]
}
@@ -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
}
]
}
+10
View File
@@ -0,0 +1,10 @@
# Content tools
`content-tools` is a Java 25 command-line validator for the ProjectCapture metadata catalogs.
Commands:
- `validate <content-root>` checks schema versions, stable IDs, references, evolution graphs, level and party metadata, spawn coverage, recipes and safe asset paths.
- `digest <content-root>` 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.
+46
View File
@@ -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<JavaExec>("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")
}
@@ -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<String> 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<String, JsonNode> documents;
private ContentBundle(Path root, Map<String, JsonNode> 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<String, JsonNode> 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<String, JsonNode> 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);
}
}
}
@@ -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 <content-root>");
stream.println(" content-tools digest <content-root>");
}
}
@@ -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<String> 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<String> expectedSpecies = validateManifest(manifest, report);
Map<String, String> moveRoles = validateMoves(bundle.document("moves.json"), report);
Set<String> 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<String> missing = new LinkedHashSet<>(expectedSpecies);
missing.removeAll(species.ids);
Set<String> 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<String> 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<String> 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<String, String> validateMoves(JsonNode document, ValidationReport report) {
Map<String, String> 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<String> validateRecipes(JsonNode document, ValidationReport report) {
Set<String> ids = new HashSet<>();
Set<String> 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<String> ids = new LinkedHashSet<>();
Map<String, String> speciesByAsset = new LinkedHashMap<>();
Map<String, Integer> 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<String, String> moveRoles,
Set<String> producedItems,
AssetIndex assets,
ValidationReport report
) {
Set<String> ids = new LinkedHashSet<>();
Set<Integer> catalogNumbers = new HashSet<>();
Map<String, String> evolutionTargets = new LinkedHashMap<>();
List<PendingSpeciesReferences> 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<String> 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<String> validateCommandMoves(
JsonNode moves,
Map<String, String> moveRoles,
String location,
ValidationReport report
) {
List<String> result = new ArrayList<>();
if (!moves.isArray() || moves.size() != 3) {
report.add("COMMAND_SLOTS", location + ".commandMoveIds", "Exactly three commanded moves are required");
return result;
}
Set<String> 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<String, String> targets, ValidationReport report) {
Map<String, Integer> state = new HashMap<>();
for (String species : targets.keySet()) {
visitEvolution(species, targets, state, new ArrayList<>(), report);
}
}
private void visitEvolution(
String species,
Map<String, String> targets,
Map<String, Integer> state,
List<String> 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<String> 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<String> species, ValidationReport report) {
Set<String> ids = new HashSet<>();
Map<String, Integer> 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<String> 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<String> 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<String> ids,
Map<String, String> speciesByAsset,
Map<String, Integer> countBySpecies
) {}
private record SpeciesIndex(Set<String> ids, Map<String, String> evolutionTargets) {}
private record PendingSpeciesReferences(String location, String id, String target, String assetId) {}
}
@@ -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;
}
}
@@ -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<ValidationIssue> 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<ValidationIssue> issues() {
return Collections.unmodifiableList(issues);
}
public boolean containsCode(String code) {
return issues.stream().anyMatch(issue -> issue.code().equals(code));
}
}
@@ -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"));
}
}
@@ -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<String> 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);
}
}
+1 -1
View File
@@ -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. - `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. - `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. - `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. 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.